Chapitre 9.5 — WordPress headless avec Next.js
⏱️ TL;DR — En headless, WordPress devient un back-office de contenu et Next.js (App Router) rend le front. On fetch l’API WordPress côté serveur (Server Components), on génère les pages en SSG avec ISR (
revalidate) ou à la demande, et on n’expose aucun secret au client. On construit HeadlessShelf : un front Next.js sur le cataloguewpr-books(Parties 7-8), avec liste et pages de détail, et revalidation à la publication via un webhook. C’est le projet vitrine de votre profil (Partie 13).
🎯 Objectifs
- Structurer un front Next.js App Router sur WordPress.
- Fetcher l’API côté serveur avec cache/ISR.
- Générer les routes dynamiques (liste + détail).
- Revalider à la publication (webhook / on-demand).
1. L’architecture headless
- WordPress gère le contenu (édition, meta, médias) — les rédacteurs gardent leur back-office familier.
- Next.js rend le site (perf, SEO, DX moderne, composants React).
- La revalidation (webhook) garde le front à jour après publication.
2. Le client WordPress (côté serveur)
Un petit module qui centralise les appels, jamais exposé au client :
// lib/wp.ts (exécuté côté serveur uniquement)
const WP = process.env.WP_API_URL!; // ex. https://cms.exemple.com/wp-json/wp/v2
export async function getLivres(): Promise<Livre[]> {
const res = await fetch(`${WP}/livre?_fields=id,slug,title,meta.wpr_prix&per_page=100`, {
next: { revalidate: 3600, tags: ['livres'] }, // ISR : re-généré au max 1×/h, invalidable par tag
});
if (!res.ok) throw new Error(`WP ${res.status}`);
return res.json();
}
export async function getLivre(slug: string): Promise<Livre | null> {
const res = await fetch(`${WP}/livre?slug=${encodeURIComponent(slug)}&_fields=id,slug,title,content,meta`, {
next: { revalidate: 3600, tags: [`livre:${slug}`] },
});
const arr = await res.json();
return arr[0] ?? null;
}- Le fetch de Next.js cache la réponse et applique ISR via
next.revalidateet des tags (revalidateTag). - On limite les champs (
_fields, ch. 9.1) pour des payloads maigres. - Aucun secret ici (lecture publique). Pour du privé (brouillons), l’auth serveur va dans le fetch (ch. 9.2 & 9.6).
3. La liste (Server Component)
// app/livres/page.tsx
import { getLivres } from '@/lib/wp';
import Link from 'next/link';
export default async function LivresPage() {
const livres = await getLivres();
return (
<ul>
{livres.map((l) => (
<li key={l.id}>
<Link href={`/livres/${l.slug}`}>{l.title.rendered}</Link>
{l.meta?.wpr_prix ? <span> — {l.meta.wpr_prix} €</span> : null}
</li>
))}
</ul>
);
}Un Server Component classique : await le fetch, map sur les données. Rien d’exotique — c’est votre quotidien Next.js, la source étant WordPress.
4. La page de détail + génération statique
// app/livres/[slug]/page.tsx
import { getLivres, getLivre } from '@/lib/wp';
import { notFound } from 'next/navigation';
// Pré-générer les pages au build (SSG)
export async function generateStaticParams() {
const livres = await getLivres();
return livres.map((l) => ({ slug: l.slug }));
}
export default async function LivrePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const livre = await getLivre(slug);
if (!livre) notFound();
return (
<article>
<h1>{livre.title.rendered}</h1>
{/* content.rendered est du HTML WordPress → sanitize avant dangerouslySetInnerHTML */}
<div dangerouslySetInnerHTML={{ __html: livre.content.rendered }} />
</article>
);
}generateStaticParamspré-génère les pages des livres au build (SSG) ; les nouvelles apparaissent via ISR/revalidation.content.renderedest du HTML WordPress : en headless, assainissez-le (ex.isomorphic-dompurify) avantdangerouslySetInnerHTML— le HTML vient d’un CMS, traité comme contenu à filtrer.
⚠️ Piège n°1 — le HTML brut du CMS. Injecter
content.renderedsans le nettoyer expose à du XSS si un compte éditeur est compromis ou si des shortcodes/embeds injectent du script. Filtrez (DOMPurify) côté rendu. Le pont WordPress→React ne dispense pas des réflexes de sécurité (ch. 7.7).
⚠️ Piège n°2 — les liens et médias. Le HTML de WordPress contient des URLs absolues vers le CMS (
cms.exemple.com/wp-content/uploads/…) et des liens internes pointant vers WordPress. En headless, il faut souvent réécrire ces URLs (médias servis depuis le CMS ou un CDN, liens internes vers vos routes Next). À prévoir dans le pipeline de rendu.
5. Revalider à la publication (on-demand ISR)
Pour que le front se mette à jour quand un rédacteur publie, WordPress appelle un webhook Next.js qui invalide le cache :
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATE_SECRET) { // secret partagé (env serveur)
return NextResponse.json({ ok: false }, { status: 401 });
}
revalidateTag('livres'); // invalide la liste ; on peut cibler `livre:${slug}`
return NextResponse.json({ revalidated: true });
}Côté WordPress, on déclenche l’appel sur save_post_livre (ch. 5.2) :
add_action( 'save_post_livre', function ( $post_id, $post ) {
if ( wp_is_post_revision( $post_id ) || 'publish' !== $post->post_status ) { return; }
wp_remote_post( 'https://app.exemple.com/api/revalidate?secret=' . MON_SECRET, array(
'blocking' => false, // non bloquant
) );
}, 10, 2 );→ à chaque publication, le front régénère les pages concernées. C’est l’ISR à la demande : statique et frais.
💡 Pour un dev React — Tout ce chapitre est du Next.js standard : Server Components qui
await fetch,generateStaticParamspour le SSG,next.revalidate/revalidateTagpour l’ISR, un route handler pour le webhook. La seule « nouveauté » est que la source de données est WordPress (que vous avez modelé en Parties 7-8). Vous appliquez vos compétences Next.js telles quelles — c’est précisément pourquoi ce profil est rare et recherché (Partie 13).
6. HeadlessShelf : ce qu’on a construit
- Un front Next.js (liste
/livres+ détail/livres/[slug]) sur le cataloguewpr-books. - SSG + ISR avec revalidation à la publication (webhook).
- Payloads optimisés (
_fields), HTML assaini, secrets côté serveur. - Les rédacteurs éditent dans WordPress ; les visiteurs voient un front moderne et rapide.
C’est le pont complet WordPress ↔ Next.js — la démonstration vivante de votre valeur.
✏️ Exercices
- Pourquoi fetcher l’API WordPress dans un Server Component plutôt que côté client ?
- Comment faire en sorte qu’une page de livre se mette à jour immédiatement après publication, sans rebuild complet ?
- Quel risque en injectant
content.rendereddirectement, et comment le gérer ?
✅ Solution
- Pour garder les éventuels secrets côté serveur (ch. 9.2), bénéficier du cache/ISR de Next, éviter le CORS (pas d’appel navigateur → CMS), et livrer du HTML statique (SEO + perf). Le client ne parle qu’à Next.
- Un webhook de revalidation on-demand : WordPress appelle
/api/revalidate(avec un secret) sursave_post, qui faitrevalidateTag('livres')/livre:${slug}→ seules les pages concernées sont régénérées, sans rebuild complet. - Un risque XSS (le HTML vient du CMS ; un éditeur compromis ou un embed malveillant pourrait injecter du script). On le gère en assainissant le HTML (ex. DOMPurify) avant
dangerouslySetInnerHTML, et en réécrivant les URLs médias/liens.
🧠 Quiz de révision
1. Qui gère le contenu, qui gère le rendu en headless ?
WordPress gère le contenu (back-office) ; Next.js gère le rendu du front.
2. Comment obtenir des pages statiques mais à jour ?
SSG + ISR : generateStaticParams + next.revalidate/revalidateTag, avec revalidation on-demand à la publication.
3. Où fetch-t-on l’API et pourquoi ?
Côté serveur (Server Components / route handlers) : secrets protégés, cache/ISR, pas de CORS.
4. Comment WordPress notifie-t-il Next.js d’une publication ?
Par un webhook (wp_remote_post sur save_post) vers un route handler qui revalidateTag.
5. Que faire du content.rendered avant affichage ?
L’assainir (DOMPurify) avant dangerouslySetInnerHTML, et réécrire les URLs médias/liens.
Chapitre suivant : Previews & brouillons en headless.