Régénération incrémentale (ISR) Next.js avec invalidation à la demande par webhook.
Next.js (App Router) sait régénérer une page à la demande grâce au cache de fetch et aux tags de cache. Le schéma idéal avec Le Commis : on fetch les données côté serveur en les marquant d’un tag, puis un webhook déclenche revalidateTag pour ne re-générer que ce qui a changé.
Tous les appels passent par le runtime serveur de Next.js. La clé d’API reste dans une variable d’environnement serveur (sans préfixe NEXT_PUBLIC_) et n’atteint jamais le navigateur.
Avec l’ISR, vos pages sont servies depuis le cache et ne rappellent l’API qu’après un revalidateTag. Vous restez ainsi très loin des quotas de requêtes, même avec beaucoup de trafic.
On taggue chaque requête par ressource (menus, business_hours, profile…) pour pouvoir invalider sélectivement selon le champ changed_resources du webhook.
const API_BASE = "https://app.lecommis.fr/api/v1";const API_KEY = process.env.LECOMMIS_API_KEY!;const SLUG = process.env.LECOMMIS_SLUG!;async function apiGet<T>(path: string, tag: string): Promise<T> { const res = await fetch(`${API_BASE}${path}`, { headers: { "X-Api-Key": API_KEY, Accept: "application/json", }, // Cache persistant invalidé uniquement par revalidateTag. next: { tags: [tag] }, }); if (!res.ok) { // 404 slug/menu/API off · 401 clé invalide · 429 quota de requêtes dépassé throw new Error(`Le Commis API ${res.status} sur ${path}`); } return (await res.json()) as T;}export interface MenuItem { id: number; name: string; description: string | null; price: string | null; // décimal en string, null hors à la carte a_la_carte: boolean; gluten_free: boolean; vegetarian: boolean; vegan: boolean; chef_recommendation: boolean;}export interface MenuDetail { content_revision: number; menu_type: { slug: string; name: string; kind: "menu" | "card" }; reference_date: string | null; sections: { name: string; position: number; items: MenuItem[] }[];}export function getMenu(menuTypeSlug: string, locale: "fr" | "en" = "fr") { // Tag par locale : le contenu diffère par langue. return apiGet<MenuDetail>( `/establishments/${SLUG}/menus/${menuTypeSlug}?locale=${locale}`, `menus:${locale}` );}export function getHours() { return apiGet(`/establishments/${SLUG}/hours`, "business_hours");}export function getEstablishment() { return apiGet(`/establishments/${SLUG}`, "profile");}
Taggez par locale (menus:fr, menus:en). Le content_revision est commun aux deux langues, mais le contenu sérialisé diffère : un seul webhook invalide les deux tags d’un coup, et chaque locale est re-générée avec son propre contenu (fallback en → fr sur les traductions manquantes).
Le webhook establishment.content_updated porte un changed_resources (sous-ensemble de profile, business_hours, special_hours, menus, master_menu). La valeur master_menu correspond à la Carte unifiée (le PDF de toutes vos cartes). On vérifie d’abord la signature HMAC, puis on n’invalide que les tags concernés.
import { revalidateTag } from "next/cache";import crypto from "node:crypto";const SECRET = process.env.LECOMMIS_WEBHOOK_SECRET!;function verifySignature( raw: string, signature: string | null, timestamp: string | null): boolean { if (!signature || !timestamp) return false; // Anti-rejeu : tolérance d'horloge de 5 minutes. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const signedPayload = `${timestamp}.${raw}`; const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(signedPayload).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && crypto.timingSafeEqual(a, b);}// Mappe une ressource changée vers les tags de cache à invalider.function tagsFor(resource: string): string[] { switch (resource) { case "menus": case "master_menu": // Carte unifiée : on rafraîchit les pages de menu return ["menus:fr", "menus:en"]; case "business_hours": case "special_hours": return ["business_hours"]; case "profile": return ["profile"]; default: return []; }}export async function POST(req: Request) { const raw = await req.text(); // corps brut requis pour la signature const signature = req.headers.get("X-LeCommis-Signature"); const timestamp = req.headers.get("X-LeCommis-Timestamp"); if (!verifySignature(raw, signature, timestamp)) { return Response.json({ error: "invalid signature" }, { status: 401 }); } const payload = JSON.parse(raw); if (payload.type === "establishment.content_updated") { const changed: string[] = payload.data?.changed_resources ?? []; const tags = new Set(changed.flatMap(tagsFor)); for (const tag of tags) { revalidateTag(tag); } } // 2xx = succès, pas de retry côté Le Commis. return Response.json({ ok: true });}
La signature porte sur "{X-LeCommis-Timestamp}.{corps brut JSON}". Lisez req.text() une seule fois, avant tout JSON.parse. Si vous re-sérialisez le payload, les octets ne correspondent plus et timingSafeEqual échoue.
Pour ignorer les doublons (par exemple après un retry), déduplicquez sur l’en-tête X-LeCommis-Delivery, qui identifie chaque livraison de façon unique. Les changements rapprochés sont déjà fusionnés côté Le Commis (debounce 30 s) : une seule livraison peut donc lister plusieurs ressources dans changed_resources.
Détail de la vérification de signature et des protections réseau : sécurité des webhooks.
Rôle de l’entier monotone et stratégie de cache : content_revision.