Bywa, że redakcja chce zostać przy panelu WordPressa, który dobrze zna, a strona potrzebuje frontendu szybszego i bardziej elastycznego, niż pozwala motyw. Wtedy sprawdza się architektura „headless”: WordPress przestaje renderować strony i zostaje wyłącznie źródłem treści, a to, co widzi odwiedzający, buduje aplikacja w Next.js. Opisuję, jak to składam: wybór API, cache i odświeżanie po zapisie wpisu, podgląd szkiców i pułapki. Kod jest pisany pod App Router w Next.js 16 i TypeScript.
Kiedy headless ma sens (a kiedy nie)
Headless opłaca się, gdy:
frontend ma być szybki i interaktywny, a zespół zna React,
te same treści trafiają do kilku miejsc (strona, aplikacja mobilna, newsletter),
chcesz oddzielić bezpieczeństwo panelu od publicznej strony (WordPress może stać pod osobną domeną, a nawet za VPN-em).
Tracisz za to sporo rzeczy „za darmo”: podgląd motywu, większość wtyczek frontowych (formularze, galerie, page buildery), a SEO i menu musisz obsłużyć samodzielnie. Jeśli strona to wizytówka z pięcioma podstronami, klasyczny motyw będzie tańszy w utrzymaniu i przy takich stronach internetowych zostaję przy nim.
REST API czy WPGraphQL?
WordPress ma wbudowane REST API pod adresem /wp-json/wp/v2/. Nie wymaga żadnej wtyczki, a każdy typ treści zarejestrowany z show_in_rest => true jest od razu dostępny. Wadą jest nadmiar danych: domyślna odpowiedź dla wpisu zawiera dziesiątki pól. Ratunkiem są parametry _fields (wybierasz pola) i _embed (dołączasz powiązane zasoby, np. obrazek wyróżniający).
WPGraphQL to wtyczka, która wystawia endpoint /graphql. Pobierasz dokładnie te pola, których potrzebujesz, i możesz jednym zapytaniem pobrać wpis, autora, kategorie oraz menu. Płacisz za to dodatkową zależnością i koniecznością pilnowania zgodności wtyczek z wersją WordPressa (np. Yoast wymaga osobnego rozszerzenia do WPGraphQL).
Moja zasada: przy prostym blogu i kilku typach treści wybieram REST API. Gdy strona ma rozbudowane relacje (ACF, wiele taksonomii, menu, strony zależne), WPGraphQL zwykle oszczędza sporo kodu. Dalsze przykłady używają REST, ale mechanizmy cache i podglądu działają identycznie dla obu.
Krok 1: warstwa dostępu do danych
Zaczynam od jednego modułu, który wie wszystko o WordPressie. Komponenty nie powinny składać adresów URL samodzielnie.
// lib/wp.ts
const WP_URL = process.env.WP_URL ?? 'https://cms.example.com'
const API = `${WP_URL}/wp-json/wp/v2`
export type WPPost = {
id: number
slug: string
status: string
title: { rendered: string }
content: { rendered: string }
yoast_head_json?: { title?: string; description?: string; canonical?: string }
_embedded?: {
'wp:featuredmedia'?: {
source_url: string
alt_text: string
media_details: { width: number; height: number }
}[]
}
}
type FetchOptions = { tags?: string[]; revalidate?: number; auth?: boolean }
async function wpFetch<T>(path: string, opts: FetchOptions = {}): Promise<T> {
const { tags = [], revalidate = 3600, auth = false } = opts
const headers: Record<string, string> = { Accept: 'application/json' }
if (auth) {
const credentials = `${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`
headers.Authorization = `Basic ${Buffer.from(credentials).toString('base64')}`
}
const res = await fetch(
`${API}${path}`,
auth
? { headers, cache: 'no-store' } // szkice nigdy nie trafiają do cache
: { headers, next: { revalidate, tags } },
)
if (!res.ok) throw new Error(`WordPress API ${res.status} dla ${path}`)
return (await res.json()) as T
}
export async function getPostBySlug(slug: string) {
const posts = await wpFetch<WPPost[]>(
`/posts?slug=${encodeURIComponent(slug)}&_embed=wp:featuredmedia`,
{ tags: ['wp-posts', `wp-post-${slug}`] },
)
return posts[0] ?? null
}
export function getRecentSlugs(limit = 20) {
return wpFetch<Pick<WPPost, 'slug'>[]>(`/posts?per_page=${limit}&_fields=slug`, {
tags: ['wp-posts'],
})
}
export async function getDraftById(id: number) {
try {
return await wpFetch<WPPost>(`/posts/${id}?context=edit`, { auth: true })
} catch {
return null
}
}
Generyczna funkcja wpFetch<T> daje typowane odpowiedzi bez powtarzania kodu, a każde zapytanie dostaje tagi: ogólny wp-posts i szczegółowy wp-post-slug. To one pozwolą później odświeżyć dokładnie te dane, które się zmieniły.
Krok 2: strona wpisu z ISR
W App Routerze od Next.js 15 params jest obiektem typu Promise, więc trzeba go „odczekać” przez await. W Next.js 16 możesz też użyć globalnego pomocnika PageProps<'/blog/[slug]'> generowanego automatycznie, ale jawny typ poniżej działa wszędzie.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { getPostBySlug, getRecentSlugs } from '@/lib/wp'
export const revalidate = 3600 // siatka bezpieczeństwa: co godzinę
type Props = { params: Promise<{ slug: string }> }
export async function generateStaticParams() {
const posts = await getRecentSlugs()
return posts.map(({ slug }) => ({ slug }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
const seo = post?.yoast_head_json
if (!post) return {}
return {
title: seo?.title ?? post.title.rendered,
description: seo?.description,
alternates: seo?.canonical ? { canonical: seo.canonical } : undefined,
}
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
const cover = post._embedded?.['wp:featuredmedia']?.[0]
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
{cover && (
<Image
src={cover.source_url}
alt={cover.alt_text}
width={cover.media_details.width}
height={cover.media_details.height}
sizes="(max-width: 768px) 100vw, 768px"
/>
)}
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</article>
)
}
Dlaczego tylko 20 najnowszych wpisów w generateStaticParams? Bo next build renderuje strony równolegle i każda wysyła kilka zapytań do WordPressa. Przy setkach wpisów łatwo „zapchać” własny serwer albo dostać odpowiedź 429 od zabezpieczeń hostingu. Reszta stron wygeneruje się przy pierwszym wejściu i trafi do cache.
Pole title.rendered zawiera już encje HTML (np. –), dlatego wstawiam je przez dangerouslySetInnerHTML. Treść pochodzi od redaktorów, ale jeśli do WordPressa ma dostęp więcej osób, rozważ sanitizację HTML-a po stronie Next.js.
Krok 3: rewalidacja na żądanie z webhookiem
Czasowe revalidate = 3600 oznacza, że poprawka literówki pojawi się na stronie nawet po godzinie. Rozwiązaniem jest webhook: WordPress po zapisaniu wpisu woła endpoint w Next.js, a ten unieważnia tagi.
// app/api/revalidate/route.ts
import { timingSafeEqual } from 'node:crypto'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
function isValidSecret(received: string | null) {
const expected = process.env.REVALIDATE_SECRET
if (!received || !expected) return false
const a = Buffer.from(received)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export async function POST(request: NextRequest) {
if (!isValidSecret(request.headers.get('x-revalidate-secret'))) {
return Response.json({ ok: false }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as { slug?: string } | null
revalidateTag('wp-posts', 'max')
if (body?.slug) revalidateTag(`wp-post-${body.slug}`, 'max')
revalidatePath('/blog')
return Response.json({ ok: true, slug: body?.slug ?? null })
}
W Next.js 16 revalidateTag przyjmuje drugi argument – profil. Wersja jednoargumentowa jest oznaczona jako przestarzała. Profil 'max' działa jak stale-while-revalidate: pierwszy odwiedzający po zmianie dostaje jeszcze starą wersję (nagłówek x-nextjs-cache: STALE), a w tle generuje się nowa. Jeśli potrzebujesz natychmiastowej świeżości, np. po zmianie ceny, użyj revalidateTag(tag, { expire: 0 }).
Po stronie WordPressa wystarczy mała wtyczka typu must-use (plik w wp-content/mu-plugins/):
<?php
/**
* Plugin Name: Headless bridge (Next.js)
*/
if (!defined('ABSPATH')) {
exit;
}
add_action('wp_after_insert_post', function (int $post_id, WP_Post $post, bool $update, ?WP_Post $post_before) {
if ($post->post_type !== 'post' || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
$was_public = $post_before instanceof WP_Post && $post_before->post_status === 'publish';
if ($post->post_status !== 'publish' && !$was_public) {
return; // zmiany w szkicach nie dotyczą publicznej strony
}
wp_remote_post(HEADLESS_FRONT_URL . '/api/revalidate', [
'headers' => ['Content-Type' => 'application/json', 'x-revalidate-secret' => HEADLESS_REVALIDATE_SECRET],
'body' => wp_json_encode(['slug' => $post->post_name]),
'timeout' => 5,
'blocking' => false,
]);
}, 10, 4);
add_filter('preview_post_link', function (string $link, WP_Post $post): string {
return add_query_arg(['secret' => HEADLESS_PREVIEW_SECRET, 'id' => $post->ID], HEADLESS_FRONT_URL . '/api/draft');
}, 10, 2);
Hak wp_after_insert_post (od WordPressa 5.6) odpala się po zapisaniu metadanych i taksonomii, więc webhook nie wyprzedzi danych. Warunek z $post_before obsługuje też cofnięcie publikacji. Stałe z sekretami zdefiniuj w wp-config.php, poza repozytorium.
Krok 4: podgląd szkiców przez Draft Mode
Redaktor klika „Podgląd” i chce zobaczyć szkic w docelowym wyglądzie. Filtr preview_post_link z wtyczki powyżej kieruje go na /api/draft. Używam ID, a nie sluga, bo szkic często nie ma jeszcze ustawionego sluga.
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getDraftById } from '@/lib/wp'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const id = Number(searchParams.get('id'))
if (searchParams.get('secret') !== process.env.PREVIEW_SECRET || !Number.isInteger(id) || id <= 0) {
return new Response('Invalid token', { status: 401 })
}
const post = await getDraftById(id)
if (!post) return new Response('Post not found', { status: 404 })
const draft = await draftMode() // od Next.js 15 draftMode() jest asynchroniczne
draft.enable()
redirect(`/podglad/${post.id}`) // ścieżka z danych CMS-a, nie z query stringa
}
Strona /podglad/[id] najpierw sprawdza (await draftMode()).isEnabled i zwraca notFound(), gdy ciasteczka brak, a następnie pobiera szkic przez getDraftById. Do wyjścia z podglądu użyj formularza z Server Action wywołującą draft.disable() – dokumentacja Next.js odradza zwykły link, bo prefetch mógłby skasować ciasteczko przed kliknięciem.
Dostęp do szkiców wymaga uwierzytelnienia. Najprościej użyć Application Passwords (wbudowane od WordPressa 5.6): w profilu użytkownika generujesz hasło aplikacji i przekazujesz je w nagłówku Authorization: Basic. Utwórz osobne konto z rolą „Redaktor” tylko dla frontendu i nigdy nie wysyłaj tego hasła do przeglądarki – getDraftById działa wyłącznie na serwerze.
Pułapki
CORS. Jeśli pobierasz dane w Server Components, CORS w ogóle cię nie dotyczy – to zapytanie serwer–serwer. Problem pojawia się dopiero przy fetch z przeglądarki, np. w wyszukiwarce na żywo. WordPress sam dokłada nagłówki CORS do odpowiedzi REST, ale wtyczki bezpieczeństwa albo reguły Nginx potrafią je usunąć. Najczystsze rozwiązanie: własny Route Handler w Next.js jako proxy.
Obrazy i next/image. Zewnętrzne obrazy muszą pasować do images.remotePatterns, inaczej optymalizator zwróci 400:
// next.config.ts
import type { NextConfig } from 'next'
const wpUrl = process.env.WP_URL ?? 'https://cms.example.com'
const nextConfig: NextConfig = {
images: {
remotePatterns: [new URL(`${wpUrl}/wp-content/uploads/**`)],
},
}
export default nextConfig
Jeśli podczas developmentu WordPress stoi na localhost lub w sieci lokalnej, Next.js 16 domyślnie odmówi optymalizacji obrazów z prywatnych adresów IP. Opcja dangerouslyAllowLocalIP to zmienia, ale włączaj ją wyłącznie lokalnie.
SEO i Yoast. Yoast SEO dodaje do odpowiedzi REST pola yoast_head (gotowy HTML) i yoast_head_json (dane). Mapuj yoast_head_json na generateMetadata, jak w kroku 2. Pamiętaj też, że canonical wskazuje na adres, który Yoast zna z ustawień WordPressa – ustaw w nim domenę frontendu albo przepisuj adresy.
Menu. Endpointy /wp/v2/menus i /wp/v2/menu-items istnieją od WordPressa 5.9, ale wymagają uprawnień do edycji motywu. Masz trzy wyjścia: pobierać menu z Application Password po stronie serwera, zarejestrować własny publiczny endpoint przez register_rest_route albo użyć WPGraphQL, który udostępnia menu przypisane do lokalizacji.
Nagłówek Authorization. Na części hostingów z Apache i PHP w trybie CGI nagłówek jest obcinany i Application Passwords „nie działają”. Wtedy potrzebna jest reguła przepisująca nagłówek do zmiennej środowiskowej w .htaccess.
Wiele instancji. Domyślny cache Next.js jest trzymany na dysku jednej instancji. Jeśli uruchamiasz kilka kontenerów, webhook unieważni cache tylko w tym, który go odebrał. Potrzebujesz wtedy współdzielonego cacheHandler.
Jak to przetestować lokalnie
Uruchom
next buildinext start– ISR nie działa w pełni w trybienext dev.Wejdź na wpis i sprawdź nagłówek
x-nextjs-cache(HIT,STALE,MISS).Wyślij webhook ręcznie:
curl -X POST localhost:3000/api/revalidate -H 'x-revalidate-secret: TWOJ_SEKRET' -H 'content-type: application/json' -d '{"slug":"hello"}'.Odśwież stronę dwa razy: pierwsza odpowiedź to
STALE, druga pokazuje już nową treść.Ciasteczko podglądu
__prerender_bypassma flagęSecure. Chrome i Firefox przyjmują takie ciasteczka nahttp://localhost, ale gdy frontend działa pod adresem IP w sieci lokalnej, podgląd trzeba testować przez HTTPS.
Od czego zacząć
Najwięcej pracy oszczędza jedna warstwa dostępu do danych z tagami, bo opiera się na niej i odświeżanie po webhooku, i podgląd szkiców. Zaczynam od REST API, a na WPGraphQL przechodzę dopiero wtedy, gdy relacji i zapytań robi się dużo. Jak pracuję przy takich projektach, opisuję na stronach aplikacje webowe i strony internetowe.
