Aller au contenu
← BlogPlateforme· 5 min de lecture

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.

DevInfra
Tableau de bord de l’admin Payload avec le bloc « Contenu de départ »

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.

Page d’accueil de koklo.dev en thème sombre
L’accueil en thème sombre, le thème par défaut quand le système ne dit rien.

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.

Qui parle à qui

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

Tableau de bord de l’admin Payload avec le bloc « Contenu de départ »
Le tableau de bord de l’admin : le bouton du contenu de départ, puis les collections rangées par usage.

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.

Formulaire d’édition du projet Shipyard dans l’admin
Un projet dans l’admin : trois onglets, des champs localisés marqués « Français », et le statut de publication en haut.

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.

Éditeur Lexical d’un article avec un bloc Diagramme Mermaid ouvert
Dans l’éditeur, un diagramme est un bloc comme un autre : la source Mermaid, une légende traduite, et le texte continue autour.

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.

Page publique du projet Shipyard
Le même projet côté site : la couleur d’accent saisie dans l’admin teinte le halo de l’en-tête.

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.

Du bouton « Publier » à la page en ligne
apps/cms/src/lib/revalidate.ts
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.

Ce que fait le bouton

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.

Médiathèque de l’admin listant les images du seed
La médiathèque après le seed : chaque image a un nom stable préfixé `seed-`, une description et une légende par langue.

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.

apps/cms/src/seed/media.ts
-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 })
seed en local

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.

apps/web/src/app/[locale]/layout.tsx
// 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){}`
Page d’accueil de koklo.dev en thème clair
La même page en clair : mêmes composants, seuls les tokens de couleur changent.

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.

Article du blog en thème clair avec sommaire et projets liés
Un article en clair : le sommaire est construit à partir des titres, les projets liés viennent d’une relation.

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_SECRET et 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 onInit n’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