Zum Inhalt springen
Alle Beiträge

13. Oktober 202610 Min. Lesezeit

Headless WordPress mit Next.js: ISR, Webhooks und Entwurfsvorschau

Wie Sie WordPress als Redaktionsoberfläche mit einem Next.js-16-Frontend verbinden: REST API oder WPGraphQL, Cache mit Tags, Aktualisierung nach dem Speichern, Entwurfsvorschau und die häufigsten Stolperfallen.

Headless WordPress mit Next.js: ISR, Webhooks und Entwurfsvorschau

Manchmal möchte die Redaktion beim vertrauten WordPress-Backend bleiben, während die Website ein schnelleres und flexibleres Frontend braucht, als ein Theme zulässt. Dann bewährt sich die „Headless“-Architektur: WordPress rendert keine Seiten mehr und bleibt ausschließlich Inhaltsquelle, während das, was der Besucher sieht, von einer Next.js-Anwendung gebaut wird. Ich beschreibe, wie ich das aufbaue: Auswahl der API, Cache und Aktualisierung nach dem Speichern eines Beitrags, Vorschau von Entwürfen und Stolperfallen. Der Code ist für den App Router in Next.js 16 und TypeScript geschrieben.

Wann Headless sinnvoll ist (und wann nicht)

Headless lohnt sich, wenn:

  • das Frontend schnell und interaktiv sein soll und das Team React kennt,

  • dieselben Inhalte an mehreren Stellen landen (Website, mobile App, Newsletter),

  • Sie die Sicherheit des Backends von der öffentlichen Website trennen möchten (WordPress kann unter einer eigenen Domain stehen, sogar hinter einem VPN).

Dafür verlieren Sie einiges, was es sonst „gratis“ gibt: die Theme-Vorschau, die meisten Frontend-Plugins (Formulare, Galerien, Page Builder), und SEO sowie Menüs müssen Sie selbst umsetzen. Ist die Website eine Visitenkarte mit fünf Unterseiten, ist ein klassisches Theme günstiger im Unterhalt, und bei solchen Webseiten bleibe ich dabei.

REST API oder WPGraphQL?

WordPress hat eine eingebaute REST API unter der Adresse /wp-json/wp/v2/. Sie braucht kein Plugin, und jeder mit show_in_rest => true registrierte Inhaltstyp ist sofort verfügbar. Der Nachteil ist ein Datenüberschuss: Die Standardantwort für einen Beitrag enthält Dutzende Felder. Abhilfe schaffen die Parameter _fields (Sie wählen die Felder) und _embed (Sie binden verknüpfte Ressourcen ein, z. B. das Beitragsbild).

WPGraphQL ist ein Plugin, das den Endpunkt /graphql bereitstellt. Sie rufen genau die Felder ab, die Sie brauchen, und können in einer Abfrage Beitrag, Autor, Kategorien und Menüs holen. Dafür zahlen Sie mit einer zusätzlichen Abhängigkeit und müssen darauf achten, dass die Plugins zur WordPress-Version passen (z. B. benötigt Yoast eine eigene Erweiterung für WPGraphQL).

Meine Regel: Bei einem einfachen Blog mit wenigen Inhaltstypen wähle ich die REST API. Hat die Website komplexe Beziehungen (ACF, viele Taxonomien, Menüs, abhängige Seiten), spart WPGraphQL meist viel Code. Die weiteren Beispiele verwenden REST, doch Cache- und Vorschaumechanismen funktionieren bei beiden identisch.

Schritt 1: Datenzugriffsschicht

Ich beginne mit einem einzigen Modul, das alles über WordPress weiß. Komponenten sollten keine URLs selbst zusammensetzen.

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' } // Entwürfe landen nie im Cache
      : { headers, next: { revalidate, tags } },
  )
  if (!res.ok) throw new Error(`WordPress API ${res.status} für ${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
  }
}

Die generische Funktion wpFetch<T> liefert typisierte Antworten ohne Code-Wiederholung, und jede Abfrage erhält Tags: das allgemeine wp-posts und das spezifische wp-post-slug. Mit ihnen lassen sich später genau die Daten aktualisieren, die sich geändert haben.

Schritt 2: Beitragsseite mit ISR

Im App Router ist params seit Next.js 15 ein Objekt vom Typ Promise, daher muss man es mit await „abwarten“. In Next.js 16 können Sie auch den automatisch generierten globalen Helfer PageProps<'/blog/[slug]'> verwenden, doch der explizite Typ unten funktioniert überall.

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 // Sicherheitsnetz: jede Stunde

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>
  )
}

Warum nur die 20 neuesten Beiträge in generateStaticParams? Weil next build Seiten parallel rendert und jede mehrere Anfragen an WordPress sendet. Bei Hunderten Beiträgen „verstopft“ man leicht den eigenen Server oder bekommt von den Schutzmechanismen des Hostings eine 429-Antwort. Die übrigen Seiten werden beim ersten Aufruf generiert und landen im Cache.

Das Feld title.rendered enthält bereits HTML-Entitäten (z. B. &#8211;), deshalb füge ich es über dangerouslySetInnerHTML ein. Die Inhalte stammen von Redakteuren, doch wenn mehrere Personen Zugriff auf WordPress haben, sollten Sie eine Sanitisierung des HTML auf der Next.js-Seite in Betracht ziehen.

Schritt 3: Revalidierung auf Abruf per Webhook

Das zeitgesteuerte revalidate = 3600 bedeutet, dass eine Tippfehlerkorrektur erst nach bis zu einer Stunde auf der Website erscheint. Die Lösung ist ein Webhook: WordPress ruft nach dem Speichern eines Beitrags einen Endpunkt in Next.js auf, der die Tags ungültig macht.

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 })
}

In Next.js 16 nimmt revalidateTag ein zweites Argument entgegen – das Profil. Die Variante mit einem Argument ist als veraltet markiert. Das Profil 'max' arbeitet wie Stale-while-revalidate: Der erste Besucher nach einer Änderung erhält noch die alte Version (Header x-nextjs-cache: STALE), im Hintergrund wird eine neue erzeugt. Wenn Sie sofortige Aktualität brauchen, z. B. nach einer Preisänderung, verwenden Sie revalidateTag(tag, { expire: 0 }).

Auf der WordPress-Seite genügt ein kleines Must-Use-Plugin (Datei in 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; // Änderungen an Entwürfen betreffen die öffentliche Website nicht
    }
    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);

Der Hook wp_after_insert_post (seit WordPress 5.6) feuert nach dem Speichern von Metadaten und Taxonomien, der Webhook läuft den Daten also nicht voraus. Die Bedingung mit $post_before deckt auch das Zurückziehen einer Veröffentlichung ab. Definieren Sie die Konstanten mit den Secrets in wp-config.php, außerhalb des Repositorys.

Schritt 4: Entwurfsvorschau über den Draft Mode

Der Redakteur klickt auf „Vorschau“ und möchte den Entwurf im endgültigen Erscheinungsbild sehen. Der Filter preview_post_link aus dem obigen Plugin leitet ihn auf /api/draft. Ich verwende die ID statt des Slugs, denn ein Entwurf hat oft noch keinen gesetzten Slug.

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() // seit Next.js 15 ist draftMode() asynchron
  draft.enable()
  redirect(`/podglad/${post.id}`) // Pfad aus den CMS-Daten, nicht aus dem Query-String
}

Die Seite /podglad/[id] prüft zuerst (await draftMode()).isEnabled und gibt notFound() zurück, wenn das Cookie fehlt, und holt dann den Entwurf über getDraftById. Zum Verlassen der Vorschau verwenden Sie ein Formular mit einer Server Action, die draft.disable() aufruft – die Next.js-Dokumentation rät von einem normalen Link ab, weil das Prefetching das Cookie vor dem Klick löschen könnte.

Der Zugriff auf Entwürfe erfordert eine Authentifizierung. Am einfachsten nutzen Sie Application Passwords (seit WordPress 5.6 eingebaut): Im Benutzerprofil erzeugen Sie ein Anwendungspasswort und übergeben es im Header Authorization: Basic. Legen Sie ein eigenes Konto mit der Rolle „Redakteur“ nur für das Frontend an und senden Sie dieses Passwort niemals an den Browser – getDraftById läuft ausschließlich auf dem Server.

Stolperfallen

CORS. Wenn Sie Daten in Server Components abrufen, betrifft Sie CORS gar nicht – es ist eine Server-zu-Server-Anfrage. Das Problem tritt erst bei fetch aus dem Browser auf, z. B. bei einer Live-Suche. WordPress fügt den REST-Antworten selbst CORS-Header hinzu, doch Sicherheits-Plugins oder Nginx-Regeln können sie entfernen. Die sauberste Lösung: ein eigener Route Handler in Next.js als Proxy.

Bilder und next/image. Externe Bilder müssen zu images.remotePatterns passen, sonst liefert der Optimierer 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

Läuft WordPress während der Entwicklung auf localhost oder im lokalen Netz, verweigert Next.js 16 standardmäßig die Optimierung von Bildern privater IP-Adressen. Die Option dangerouslyAllowLocalIP ändert das, aktivieren Sie sie aber ausschließlich lokal.

SEO und Yoast. Yoast SEO fügt den REST-Antworten die Felder yoast_head (fertiges HTML) und yoast_head_json (Daten) hinzu. Bilden Sie yoast_head_json wie in Schritt 2 auf generateMetadata ab. Beachten Sie außerdem, dass der Canonical auf die Adresse zeigt, die Yoast aus den WordPress-Einstellungen kennt – tragen Sie dort die Domain des Frontends ein oder schreiben Sie die Adressen um.

Menüs. Die Endpunkte /wp/v2/menus und /wp/v2/menu-items gibt es seit WordPress 5.9, sie erfordern aber Berechtigungen zum Bearbeiten des Themes. Sie haben drei Möglichkeiten: die Menüs serverseitig mit einem Application Password abrufen, über register_rest_route einen eigenen öffentlichen Endpunkt registrieren oder WPGraphQL verwenden, das den Positionen zugewiesene Menüs bereitstellt.

Header Authorization. Bei manchen Hostings mit Apache und PHP im CGI-Modus wird der Header abgeschnitten, und Application Passwords „funktionieren nicht“. Dann ist in der .htaccess eine Regel nötig, die den Header in eine Umgebungsvariable umschreibt.

Mehrere Instanzen. Der Standard-Cache von Next.js liegt auf der Festplatte einer einzelnen Instanz. Starten Sie mehrere Container, macht der Webhook den Cache nur in dem ungültig, der ihn empfangen hat. Dann brauchen Sie einen gemeinsamen cacheHandler.

So testen Sie es lokal

  1. Starten Sie next build und next start – ISR funktioniert im Modus next dev nicht vollständig.

  2. Rufen Sie einen Beitrag auf und prüfen Sie den Header x-nextjs-cache (HIT, STALE, MISS).

  3. Senden Sie den Webhook manuell: curl -X POST localhost:3000/api/revalidate -H 'x-revalidate-secret: TWOJ_SEKRET' -H 'content-type: application/json' -d '{"slug":"hello"}'.

  4. Laden Sie die Seite zweimal neu: Die erste Antwort ist STALE, die zweite zeigt bereits den neuen Inhalt.

  5. Das Vorschau-Cookie __prerender_bypass trägt das Flag Secure. Chrome und Firefox akzeptieren solche Cookies auf http://localhost, läuft das Frontend aber unter einer IP-Adresse im lokalen Netz, muss die Vorschau über HTTPS getestet werden.

Womit Sie anfangen sollten

Die meiste Arbeit spart eine einzige Datenzugriffsschicht mit Tags, denn auf ihr beruhen sowohl die Aktualisierung per Webhook als auch die Entwurfsvorschau. Ich beginne mit der REST API und wechsle erst dann zu WPGraphQL, wenn Beziehungen und Abfragen zahlreich werden. Wie ich bei solchen Projekten arbeite, beschreibe ich auf den Seiten Web-Apps und Webseiten.

Schreiben