Skip to main content
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.

Variables d’environnement

.env.local
LECOMMIS_API_KEY=votre_cle_serveur_a_serveur
LECOMMIS_SLUG=au-bistrot
LECOMMIS_WEBHOOK_SECRET=votre_signing_secret

Fetch côté serveur avec tag de cache

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 enfr sur les traductions manquantes).

Route de revalidation déclenchée par webhook

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.