Przejdź do treści
Wszystkie wpisy

13 października 20269 min czytania

Headless WordPress z Next.js: ISR, webhooki i podgląd szkiców

Jak połączyć WordPress jako panel redakcji z frontendem w Next.js 16: REST API czy WPGraphQL, cache z tagami, odświeżanie po zapisie wpisu, podgląd szkiców i pułapki, na które trafia się najczęściej.

Headless WordPress z Next.js: ISR, webhooki i podgląd szkiców

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.

TypeScript
// 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.

TSX
// 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. &#8211;), 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.

TypeScript
// 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
<?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.

TypeScript
// 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:

TypeScript
// 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

  1. Uruchom next build i next start – ISR nie działa w pełni w trybie next dev.

  2. Wejdź na wpis i sprawdź nagłówek x-nextjs-cache (HIT, STALE, MISS).

  3. 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"}'.

  4. Odśwież stronę dwa razy: pierwsza odpowiedź to STALE, druga pokazuje już nową treść.

  5. Ciasteczko podglądu __prerender_bypass ma flagę Secure. Chrome i Firefox przyjmują takie ciasteczka na http://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.

Napisz