Połącz WordPressa z Next.js App Router przez API. Poznaj bezpieczną konfigurację produkcyjną bez problemów z ISR, trybem podglądu i webhookami.
Maciej Sala
Founder StriveLab
8 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 1 sierpnia 2026)
WPGraphQL i Next.js App Router: zakres implementacji headless WordPress
Jeśli decyzja ze wstępu jeszcze nie zapadła, zacznij od Headless WordPress, kiedy ma sens, a kiedy nie.
Artykuł jest technicznym tutorialem wdrożeniowym, który mówi jak połączyć WordPress, i Next.js w działającą architekturę. Headless WordPress to WordPress używany wyłącznie jako , z Next.js jako frontendem.
WordPress udostępnia treści przez ( lub GraphQL), a Next.js pobiera je, renderuje i serwuje użytkownikowi. W efekcie znany edytor WordPress dla klienta łączy się z wydajnością i Next.js na frontendzie.
Kiedy headless WordPress z Next.js ma sens?
Istniejący WordPress z setkami artykułów. Klient nie chce migrować treści,
ale chce szybszy frontend.
Zespół redakcyjny zna WordPress i nie chce uczyć się Sanity/Strapi.
Zaawansowane pola ACF (Advanced Custom Fields). Pola własne, elastyczna
zawartość (flexible content) oraz grupy pól.
WooCommerce. Koszyk i checkout w wariancie headless z Next.js, ale
zarządzanie produktami w WordPressie.
Kiedy zamiast WordPressa wybrać dedykowany ?
Nowy projekt od zera, w którym model treści, podgląd i praca wielojęzyczna
są ważniejsze niż zgodność z ekosystemem WordPressa.
Nie potrzebujesz ekosystemu wtyczek WP. Mniej zależności oznacza mniej
problemów.
Jeden deweloper. Utrzymanie WordPress i Next.js to dwa systemy do
zarządzania.
Konfiguracja WPGraphQL w WordPressie i Next.js
Code
# W WordPressie zainstaluj wtyczki:# 1. WPGraphQL (wp-graphql)# 2. WPGraphQL for ACF (opcjonalnie, jeśli używasz ACF)
Po instalacji WPGraphQL endpoint jest dostępny
pod adresem https://cms.example.com/graphql.
Klient GraphQL w Next.js App Router
Code
// lib/wordpress.tsimport 'server-only'const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL!interface GraphQLResponse<T> { data?: T errors?: { message: string; path?: (string | number)[] }[]}export async function fetchGraphQL<T>( query: string, variables?: Record<string, unknown>, options: { tags?: string[]; revalidate?: number; authToken?: string } = {},): Promise<T> { if (!WORDPRESS_API_URL) { throw new Error('Missing WORDPRESS_API_URL') } const cacheOptions = options.authToken ? ({ cache: 'no-store' } as const) : { next: { revalidate: options.revalidate ?? 3600, tags: options.tags, }, } const res = await fetch(WORDPRESS_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(options.authToken && { Authorization: `Bearer ${options.authToken}`, }), }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(8_000), ...cacheOptions, }) if (!res.ok) { throw new Error(`WordPress GraphQL HTTP ${res.status}`) } const json = (await res.json()) as GraphQLResponse<T> // GraphQL może zwrócić HTTP 200 razem z errors i częściowym data. if (json.errors?.length || json.data === undefined) { throw new Error( json.errors?.map((error) => error.message).join(', ') || 'WordPress GraphQL response has no data', ) } return json.data}
Uwierzytelnione odpowiedzi nie trafiają w tym przykładzie do współdzielonego
cache Next.js. Prywatna treść wymaga osobnej ścieżki odczytu, ponieważ tagi
i czas rewalidacji używane dla wpisów publicznych nie mogą objąć szkiców innego
redaktora. Ważne, by dane publiczne pobierać anonimowo, a autoryzację stosować w trybie podglądu.
WPGraphQL nie dostarcza samodzielnego mechanizmu logowania dla zewnętrznego
frontendu. Możesz dobrać rozszerzenie WPGraphQL Headless Login, WPGraphQL JWT
Authentication albo Application Passwords, zależnie od przepływu i środowiska.
Poświadczenia przesyłaj w nagłówku, przechowuj w module server-only, regularnie
rotuj i przypisz do konta mającego wyłącznie uprawnienia potrzebne do podglądu.
Typ generyczny T nie sprawdza odpowiedzi w czasie działania aplikacji. W większym projekcie generuj typy na podstawie schematu i dokumentów GraphQL (GraphQL Code Generator) oraz waliduj krytyczne granice, szczególnie gdy aktualizacja wtyczki może zmienić schemat. W CI uruchamiaj kontrolę dokumentów względem schematu ze środowiska staging przed wdrożeniem WordPressa.
WPGraphQL używa paginacji Relay opartej na kursorach. first: 1000 nie jest bezpiecznym skrótem: domyślny limit połączenia wynosi 100, a jego zwiększenie może mocno obciążyć PHP i bazę. Dla listy użytkowej pobieraj 10 do 100 elementów i wystaw „Następna strona” lub infinite scroll. Funkcja getAllPostSlugs przydaje się w migracji lub generatorze sitemap, lecz nie musi działać podczas każdego wdrożenia frontendu.
Pola seo nie należą do WPGraphQL core. Jeśli chcesz pobierać metadane Yoast albo Rank Math, potrzebujesz kompatybilnego rozszerzenia, a nazwy pól musisz potwierdzić w swoim schemacie. Nie deklaruj ich wyłącznie w interfejsie TypeScript, bo typ nie sprawi, że serwer GraphQL zacznie je zwracać.
Sanityzacja HTML z WordPressa
WPGraphQL zwraca content i excerpt jako wyrenderowany HTML. WordPress filtruje treść zależnie od uprawnień autora, ale administrator może mieć unfiltered_html, wtyczka może dołożyć niebezpieczny markup, a przejęty CMS staje się źródłem . Przy dangerouslySetInnerHTML ustaw własną granicę zaufania i listę dozwolonych elementów.
Listę dozwolonych elementów dopasuj do bloków Gutenberga faktycznie używanych w projekcie. Osadzenia iframe, formularze, style inline i skrypty wymagają osobnej decyzji. Nie dopuszczaj ich globalnie z powodu jednego wpisu i dołóż restrykcyjne jako drugą warstwę ochrony.
// app/blog/[slug]/page.tsximport { getPostBySlug } from '@/lib/queries/posts'import { notFound } from 'next/navigation'import type { Metadata } from 'next'import { sanitizeWordPressHtml, wordpressText,} from '@/lib/sanitize-wordpress-html'export const revalidate = 3600export const dynamicParams = trueexport async function generateStaticParams() { // Wpisy powstaną jako strony ISR przy pierwszym żądaniu. return []}export async function generateMetadata({ params,}: { params: Promise<{ slug: string }>}): Promise<Metadata> { const { slug } = await params const post = await getPostBySlug(slug) if (!post) return {} const description = wordpressText(post.excerpt).slice(0, 160) return { title: post.title, description, alternates: { canonical: `/blog/${post.slug}` }, openGraph: { type: 'article', title: post.title, description, publishedTime: post.date, modifiedTime: post.modified, images: post.featuredImage ? [post.featuredImage.node.sourceUrl] : undefined, }, }}export default async function BlogPost({ params,}: { params: Promise<{ slug: string }>}) { const { slug } = await params const post = await getPostBySlug(slug) if (!post) notFound() return ( <article className="prose prose-lg mx-auto max-w-3xl py-12"> <h1>{post.title}</h1> <time className="text-gray-400"> {new Date(post.date).toLocaleDateString('pl-PL')} </time> <div dangerouslySetInnerHTML={{ __html: sanitizeWordPressHtml(post.content), }} /> </article> )}
Relatywny adres kanoniczny wymaga poprawnego metadataBase w głównym layoucie. W sytuacji, w której korzystasz z rozszerzenia SEO WordPressa, ustal jedno źródło prawdy dla tytułu, adresu kanonicznego, noindex i Open Graph. Nie mieszaj części pól z wtyczki z innymi, generowanymi niezależnie w Next.js. Pamiętaj też, by dla danych strukturalnych Article serializować bezpiecznie JSON (co najmniej zamień < na \u003c) i nie kopiuj w ciemno JSON-LD wygenerowanego dla starego frontendu WordPressa.
Powyższy wariant używa modelu cache bez cacheComponents, zgodnego z next.revalidate na fetch i segmentowym export const revalidate. Jeśli w Next.js 16 włączysz cacheComponents: true, konfiguracje segmentu takie jak revalidate są zastępowane przez jawne zakresy 'use cache', cacheLife i cacheTag. Wybór powinien być zapisany w next.config.ts i testowany po buildzie produkcyjnym.
Pusta tablica z dynamicParams = true pozwala w tym modelu budować strony ISR
przy pierwszej wizycie i nie uzależnia wdrożenia od pobrania wszystkich slugów.
Jeśli chcesz wygenerować część popularnych wpisów podczas builda, sprawdź
zachowanie wybranej konfiguracji w dokumentacji swojej wersji Next.js. Po
włączeniu cacheComponents obowiązują inne reguły dla dynamicznych parametrów i
co najmniej jeden przykładowy parametr może być wymagany podczas budowania.
Ustawienie dynamicParams = false zwróci 404 dla adresu, którego nie było w
wyniku generateStaticParams. Nowa publikacja nie pojawi się wtedy dzięki
samemu webhookowi, bo funkcja parametrów nie uruchamia się ponownie podczas ISR.
Bezpieczny podgląd szkiców przez Next.js Draft Mode
Endpoint podglądu powinien przyjąć identyfikator wpisu, czas wygaśnięcia i podpis
HMAC utworzony po stronie WordPressa. Dzięki temu wspólny sekret nie trafia do
adresu, historii przeglądarki ani logów proxy. Po weryfikacji endpoint pobiera
szkic samodzielnie i przekierowuje wyłącznie na slug zwrócony przez CMS.
Code
// app/api/draft/route.tsimport { createHmac, timingSafeEqual } from 'node:crypto'import { draftMode } from 'next/headers'import { redirect } from 'next/navigation'import { z } from 'zod'const previewQuerySchema = z.object({ id: z.coerce.number().int().positive(), expires: z.coerce.number().int().positive(), signature: z.string().regex(/^[a-f0-9]{64}$/i),})function hasValidPreviewSignature( id: number, expires: number, signature: string,) { const secret = process.env.DRAFT_SECRET const now = Math.floor(Date.now() / 1000) if (!secret || expires < now || expires > now + 300) return false const expected = createHmac('sha256', secret) .update(`${id}.${expires}`) .digest() const received = Buffer.from(signature, 'hex') return ( received.length === expected.length && timingSafeEqual(received, expected) )}export async function GET(request: Request) { const { searchParams } = new URL(request.url) const parsed = previewQuerySchema.safeParse({ id: searchParams.get('id'), expires: searchParams.get('expires'), signature: searchParams.get('signature'), }) if ( !parsed.success || !hasValidPreviewSignature( parsed.data.id, parsed.data.expires, parsed.data.signature, ) ) { return new Response('Invalid preview request', { status: 401 }) } // Funkcja wysyła poświadczenia w nagłówku i zawsze używa cache: 'no-store'. const post = await getAuthenticatedPreviewPost(parsed.data.id) if (!post?.slug) return new Response('Draft not found', { status: 404 }) const draft = await draftMode() draft.enable() redirect(`/blog/${encodeURIComponent(post.slug)}`)}
Kod tworzący link po stronie WordPressa musi policzyć HMAC z dokładnie tego
samego ciągu ${id}.${expires} i nadać maksymalnie pięć minut ważności. Sam
podpis chroni wejście do Draft Mode, natomiast WordPress nadal sprawdza
tożsamość konta technicznego oraz jego capabilities przy odczycie szkicu.
Na stronie wpisu sprawdzaj status przez await draftMode(). W sytuacji, kiedy podgląd jest
aktywny, wywołaj getAuthenticatedPreviewPost bez cache. Dla zwykłego ruchu
korzystaj z getPostBySlug. Pamiętaj, że token WordPressa nie może znaleźć się w zmiennej z
prefiksem NEXT_PUBLIC_, komponencie klienckim ani adresie podglądu. Trzeba dodać także
endpoint wyłączający Draft Mode i ustaw prefetch={false} dla prowadzącego do
niego linku, aby prefetch nie usunął ciasteczka bez działania użytkownika.
Szkice mogą zawierać poufne informacje. Konto techniczne powinno mieć minimalne
uprawnienia, a aplikacja powinna rejestrować nieudane próby bez zapisywania
podpisu i danych wpisu. W systemie o wyższym ryzyku wymagaj również zalogowania
redaktora we frontendzie. Podpis linku nie identyfikuje konkretnej osoby.
W WordPressie konfiguruj wysyłkę webhooków tylko dla zmian statusu lub treści,
które wpływają na frontend. Odfiltruj autosave, rewizje oraz nieużywane typy
wpisów. Podpis HMAC obliczaj na identycznym, surowym ciele żądania i znaczniku
czasu. Dla event_id stosuj unikalny indeks. Mechanizm idempotencji musi
pozwolić ponowić zdarzenie oznaczone jako nieudane, lecz nie może równolegle
uruchomić dwóch prób z tym samym identyfikatorem.
revalidateTag(..., 'max') stosuje stale-while-revalidate, więc pierwszy odczyt może jeszcze dostać starą wartość. revalidatePath unieważnia wynik konkretnej trasy i pomaga przy publikacji lub wycofaniu wpisu. W sytuacji wymagania natychmiastowej widoczności zmiany, przetestuj strategię wygaszania dla używanego modelu cache.
Zmiana sluga wymaga przekierowania 301
Unieważnienie starej ścieżki nie tworzy przekierowania. Po zmianie sluga
/blog/stary-adres pobierze null z WPGraphQL i skończy jako 404, co traci
sygnały zgromadzone przez poprzedni URL. Webhook powinien zapisać parę
previous_slug -> slug w trwałym rejestrze przekierowań. Strona dynamiczna lub
warstwa routingu sprawdza ten rejestr po braku wpisu i wykonuje
permanentRedirect do aktualnego adresu.
Łańcuch przekierowań wymaga spłaszczenia do celu. Gdy slug zmieni się drugi
raz, wszystkie historyczne warianty powinny prowadzić bezpośrednio do najnowszej
wersji. Zablokuj pętle, przechowuj datę zmiany i uwzględnij mapę w testach po
migracji treści.
Ogranicz remotePatterns do rzeczywistej ścieżki uploadów i konkretnych hostów CDN. Zbyt szeroki wzorzec pozwala używać optymalizatora obrazów jako proxy dla niepotrzebnych zasobów. Gdy WordPress zwraca adresy po migracji domeny albo CDN przepisuje hostname, znormalizuj URL w jednej funkcji i przetestuj oba środowiska. W przypadku plików SVG zachowaj szczególną ostrożność i nie włączaj dangerouslyAllowSVG bez odpowiedniej polityki CSP oraz Content-Disposition.
Zawsze pobieraj mediaDetails.width i height albo utrzymuj znane proporcje wariantu obrazu. Atrybut sizes jest niezbędny przy responsywnym layoucie, aby przeglądarka nie pobierała zbyt dużych plików, pamiętaj jednak, że next/image nie naprawi pliku o wadze 15 MB ani braku właściwych wariantów w bibliotece mediów.
Bezpieczeństwo i utrzymanie WPGraphQL na produkcji
Headless nie usuwa obowiązków WordPressa. Endpoint /graphql nadal uruchamia PHP i zapytania do bazy, a panel administracyjny pozostaje powierzchnią ataku. Aktualizuj WordPress, WPGraphQL i rozszerzenia, usuwaj nieużywane wtyczki, rób testowane backupy oraz monitoruj błędy i czas odpowiedzi. Oddziel staging od produkcji i sprawdzaj kompatybilność schematu przed aktualizacją.
Stosuj rozsądne limity paginacji, głębokości lub złożoności zapytań, limitowanie ruchu na odwrotnym proxy oraz trwały object cache, jeśli obciążenie tego wymaga. WPGraphQL Smart Cache może pomóc, ale nie zastępuje pomiaru. Zapytania GraphQL wysyłane metodą POST nie korzystają automatycznie z typowego cache CDN tak jak stabilne GET; jeśli publiczny ruch trafia bezpośrednio do WordPressa, rozważ persisted/safelisted queries albo warstwę cache świadomą GraphQL.
Limit ciała webhooka w kodzie odrzuca zbyt duży payload po jego odczytaniu, dlatego trzeba ustawić dodatkowy limit na reverse proxy lub platformie, aby zbyt duże żądanie nie
zajęło pamięci procesu Next.js. Trzeba dodać też timeout dla połączeń z WordPressem,
metryki błędów GraphQL i alert na wzrost czasu odpowiedzi. Pamiętaj, że cache nie naprawi
przeciążonego źródła danych.
Nie wystawiaj w schemacie pól na zapas, bo WPGraphQL respektuje wiele uprawnień WordPressa, ale każde rozszerzenie może dodawać własne typy i resolvery. Przetestuj schemat jako użytkownik anonimowy, redaktor i konto podglądu, a introspekcję oraz IDE produkcyjne skonfiguruj zgodnie z przyjętym modelem zagrożeń.
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
WPGraphQL czy REST API, co wybrać do headless WordPress?
WPGraphQL daje typowany schemat, relacje i precyzyjny wybór pól, co pomaga przy złożonych modelach treści. REST jest prostszy operacyjnie, działa bez dodatkowej wtyczki i łatwiej korzysta z cache HTTP/CDN. GraphQL nie gwarantuje mniejszej ani szybszej odpowiedzi, bo nieprawidłowo ograniczone zapytanie może być bardziej kosztowne niż REST. Wybór zależy od modelu i infrastruktury.
Czy muszę utrzymywać dwa serwery?
Utrzymujesz dwa środowiska uruchomieniowe: WordPress z bazą oraz frontend Next.js, choć mogą działać u jednego dostawcy. WordPress nadal obsługuje GraphQL, redakcję, media i webhooki, więc jego wydajność, backupy, aktualizacje i bezpieczeństwo bezpośrednio wpływają na proces budowania aplikacji oraz świeżość frontendu. Tani hosting współdzielony nie zawsze wystarczy.
Jak obsłużyć podgląd szkicu (preview) z WordPressa?
Draft Mode w Next.js tylko omija statyczny cache, nie daje dostępu do prywatnych danych WordPressa. Potrzebujesz podpisanego URL-a podglądu, uwierzytelnionego zapytania WPGraphQL z kontem o minimalnych uprawnieniach oraz obsługi rewizji lub szkicu. Integrację można napisać samodzielnie albo użyć utrzymywanego rozwiązania zgodnego z wybranym mechanizmem uwierzytelniania.
Dlaczego next/image nie ładuje zdjęć z WordPressa?
Domyślnie next/image optymalizuje tylko obrazy z dozwolonych hostów, więc dodaj host swojego WordPressa do images.remotePatterns w next.config. W innym wypadku Next.js odrzuci zewnętrzne URL-e z biblioteki mediów.
Jak odświeżać front po publikacji w WordPressie?
Przez podpisany webhook reagujący na zmianę statusu wpisu. Endpoint w Next.js unieważnia tag kolekcji, tag wpisu oraz ścieżki nowego i starego sluga. Odfiltruj autosave i rewizje, waliduj ciało żądania, chroń się przed ponowieniem starego żądania i ponawiaj nieudane dostarczenia.
O autorze
Maciej Sala
Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.
Headless WordPress wygląda na prosty manewr: odcinasz motyw PHP, podpinasz Next.js i zachowujesz panel znany redakcji. Prawdziwa operacja zaczyna się później, gdy szkic ma otworzyć się na właściwym adresie, publikacja musi odświeżyć cache, blok Gutenberga potrzebuje odpowiednika w React, a stary URL nie może stracić pozycji. Ten przewodnik pomaga policzyć całą architekturę, zanim pierwsze szybkie demo zamieni się w kosztowne utrzymanie dwóch systemów.
Maciej Sala
Founder StriveLab
Z jednej strony mamy WordPressa, weterana, który wciąż napędza ponad 40% globalnego internetu. Z drugiej Next.js, flagowy framework wśród nowoczesnych technologii opartych na Reakcie. Cel obu narzędzi jest ten sam, by wypuścić stronę w świat. Różnią się jednak diametralnie drogą do tego celu, a wybór między nimi to kwestia tego, co konkretny projekt ma osiągnąć i kto będzie go utrzymywał.
Maciej Sala
Founder StriveLab
WordPress to nie tylko PHP i szablony, ponieważ od wersji 4.7 ma wbudowane REST API , dzięki któremu może funkcjonować jako headless CMS . Backend w WordPressie, frontend w React lub Next.js to w wielu projektach bardzo rozsądne rozwiązanie.