Un front Next.js et un CMS Payload séparés, une revalidation par tags, des blocs Lexical, un seed idempotent avec ses captures, et un thème clair sans flash. Visite guidée, captures prises en local.

Le site que vous lisez est mon portfolio. Il fallait qu’il montre mes projets et mes articles, en français et en anglais, et que je puisse le mettre à jour sans redéployer. Il fallait aussi qu’il soit construit comme ce que je livre à mes clients : typé, testé, déployé depuis un manifest. Voici comment il est fait, avec des captures prises sur ma machine.

Deux apps, un monorepo
Le repo est un monorepo pnpm piloté par Turborepo. Il contient deux applications et deux packages :
apps/web: le site public, en Next.js 16 avec next-intl pour le FR et l’EN.apps/cms: Payload 3, son admin et son API REST, sur PostgreSQL. Les médias vont dans MinIO par le plugin S3.packages/cms-types: les types générés par Payload, importés par le front. Si je renomme un champ, le front ne compile plus.packages/config: la configuration TypeScript et ESLint partagée.
Le front et le CMS sont deux services séparés. Le front ne touche jamais la base : il lit l’API REST du CMS, comme le ferait n’importe quel client. Les images ne passent pas par lui non plus, le navigateur les lit directement sur le domaine des médias.
Ce découpage a un coût : il faut faire savoir au front que le contenu a changé. Mais il garde l’admin, avec ses dépendances lourdes, hors du site public, et chaque service se construit et se redémarre seul.
L’admin, pensé pour la saisie

L’admin est l’outil où je passe le plus de temps, alors je l’ai rangé comme j’écris : le contenu (projets, blog, médias), le profil, le freelance, puis les réglages. Un projet se remplit en trois onglets. La présentation sert à la carte et à l’en-tête, le cas d’étude au récit, et le SEO aux balises.

Les champs de texte sont localisés. Je remplis le français, je passe la locale en anglais en haut à droite, et je retrouve le même formulaire. Les champs qui ne dépendent pas de la langue, comme le slug, la stack, les liens ou la couleur d’accent, ne se saisissent qu’une fois. Les brouillons et l’autosave sont activés, et un bouton d’aperçu ouvre la page en mode brouillon sur le front.
Des blocs plutôt que du HTML
Le récit d’un projet et le corps d’un article utilisent l’éditeur Lexical de Payload. En plus du texte, des listes, des tableaux et des images, j’y ai ajouté cinq blocs : code, encadré, diagramme Mermaid, terminal et vidéo YouTube.

Chaque bloc est rendu par un composant dédié. Le code est coloré côté serveur par Shiki, avec les thèmes GitHub clair et sombre, et rien n’est envoyé au navigateur pour ça. Mermaid fait l’inverse : la librairie est lourde, donc elle n’est chargée que quand le diagramme entre dans l’écran, et ses couleurs sont lues sur les tokens du thème courant. Les schémas de cet article en sont des exemples.

Publier sans redéployer
Le front met en cache chaque réponse du CMS avec des tags : projects, projects:shipyard, posts, etc. Quand j’enregistre un document, un hook afterChange du CMS appelle /api/revalidate sur le front avec un secret partagé, et les tags concernés sont invalidés. La page suivante est régénérée avec le contenu frais, puis servie depuis le cache.
export async function revalidateWeb(tags: string[], req?: PayloadRequest) {
const url = process.env.WEB_URL
const secret = process.env.REVALIDATE_SECRET
if (!url || !secret || req?.context?.disableRevalidate) return
try {
const res = await fetch(`${url.replace(/\/$/, '')}/api/revalidate`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-revalidate-secret': secret },
body: JSON.stringify({ tags }),
signal: AbortSignal.timeout(5000),
})
if (!res.ok) req?.payload.logger.warn(`revalidate ${tags.join(',')} → HTTP ${res.status}`)
} catch (err) {
req?.payload.logger.warn({ err }, `revalidate ${tags.join(',')} failed`)
}
}Deux règles se lisent dans ce code. Un front injoignable ne bloque jamais un enregistrement : l’appel a un délai de 5 secondes et un échec n’est qu’un avertissement dans les logs. Et le seed peut couper la revalidation document par document pour la faire en une seule fois à la fin. Le cache a aussi une durée de vie maximale d’une heure, en filet de sécurité si une invalidation se perd.
Un contenu de départ chargé depuis l’admin
Un portfolio vide ne se relit pas. J’ai donc écrit le contenu de départ dans le code : projets, compétences, offres, profil et articles, chacun en FR et en EN, rédigés à partir des repos des projets. Il se charge depuis le bouton du tableau de bord, en brouillon ou publié directement.
Le seed est idempotent. Les documents sont retrouvés par slug et mis à jour, jamais dupliqués. Les images sont des fichiers webp versionnés dans apps/cms/seed-assets/. Chacune est uploadée une seule fois, repérée par un nom stable du type seed-projects-jobspark-dashboard.webp, puis citée par clé dans le texte. Un test vérifie que chaque image citée existe sur le disque.

Les captures elles-mêmes viennent d’un petit script Playwright qui lit une liste de prises en JSON : URL, cookie de session, thème, zone à cadrer. Il a servi à prendre les écrans de Jobspark, de Civinat ou de Koklo, lancés en local avec des données de démo, et ceux de cet article.
Le bug que le seed m’a appris
Au premier essai, la base contenait bien 62 médias, mais seules 2 images existaient dans MinIO. Les autres pages affichaient leur texte alternatif. La cause tenait en une ligne. Le seed passait le même objet context à chaque appel de Payload. Le plugin de stockage, lui, pose un drapeau skipCloudStorage dans ce contexte pendant qu’il enregistre les métadonnées d’un upload. Avec un objet partagé, ce drapeau se retrouvait sur les uploads suivants, qui étaient alors sautés en silence.
-const ctx = { disableRevalidate: true }
+// Un objet neuf par appel : le plugin de stockage pose des drapeaux dans
+// `req.context`, et un contexte partagé lui ferait sauter les uploads suivants.
+const ctx = () => ({ disableRevalidate: true })Clair, sombre, et pas de flash
Le site suit le thème du système tant que le visiteur n’a rien choisi, et retient son choix ensuite. Toutes les couleurs sont des variables CSS redéfinies sous [data-theme="light"], donc aucun composant ne connaît le thème. La difficulté est le premier affichage : si le thème est posé après l’hydratation, la page clignote. Un script en ligne de deux lignes, dans le <head>, pose l’attribut avant que quoi que ce soit ne s’affiche.
// Pose data-theme avant le premier rendu (choix mémorisé, sinon thème système) : pas de flash.
const THEME_SCRIPT = `try{var t=localStorage.getItem('theme');
if(t!=='light'&&t!=='dark')t=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
document.documentElement.dataset.theme=t}catch(e){}`
Côté React, le thème est lu avec useSyncExternalStore sur l’attribut du document. Le bouton, les diagrammes Mermaid et les images qui dépendent du thème se mettent à jour ensemble, sans contexte React à faire descendre.

En production
Le site est déployé par ma PaaS as code. Un fichier .deploy/manifest.yaml décrit quatre services : le front, le CMS, MinIO et Postgres. Staging se déploie à chaque push sur develop, la production à la demande. Deux détails viennent de ce projet :
PAYLOAD_SECRETet les clés root de MinIO sont des secrets verrouillés : les changer invaliderait les sessions et la configuration chiffrée de MinIO, donc la plateforme refuse qu’ils bougent.- Le healthcheck du CMS répond 503 tant que
onInitn’a pas tourné. Au premier déploiement de staging, une première initialisation ratée avait laissé un CMS « sain » sans bucket ni compte admin. Désormais, l’orchestrateur redémarre le conteneur.
Ce que j’en retiens
Séparer le front et le CMS m’a coûté un mécanisme de revalidation, mais c’est ce qui me laisse publier une correction en un clic sans toucher au déploiement. Mettre le contenu de départ dans le code, images comprises, a été plus utile que prévu : il se relit en revue de code, se teste, et un environnement neuf est présentable en une minute. Et le bug du contexte partagé me rappelle qu’un import qui « réussit » ne prouve rien tant qu’on n’a pas regardé le résultat.
#payload#nextjs#turborepo#lexical#mermaid#playwright#minio