Routage & Rendu

Les routes vivent dans src/app/ sous la forme d'un arbre fondé sur les fichiers. L'imbrication des répertoires correspond à l'imbrication des URL, et des noms de fichiers spéciaux définissent ce que rend chaque segment.

Fichiers de route

Fichier Rôle
page.tsx La page. L'export par défaut est le composant Page ; props { params, searchParams, data?, actionData? }.
layout.tsx Enveloppe son sous-arbre : ({ children, route }) => Renderable, où route vaut { pathname, host }. S'imbrique par répertoire. Le layout racine possède <html>.
route.ts Gestionnaire HTTP. Exportez GET / POST / PUT / PATCH / DELETE / OPTIONS / HEAD, chacune de type (request, { params }). Voir « Routes API » ci-dessous.
loading.tsx Fallback Suspense pour le segment.
error.tsx Frontière d'erreur : ({ error, reset, route }).
not-found.tsx Rendue dans les layouts quand un loader ou une action retourne ou lance une Response 404, ou lance notFound().
template.tsx Comme un layout, enveloppe la page à l'intérieur des layouts.

Pour loading.tsx, error.tsx, not-found.tsx et template.tsx, c'est le fichier le plus proche, en remontant depuis le répertoire de la page, qui s'applique. Un fichier de page peut aussi exporter des fonctions de données (loader / action), voir Loaders & Actions.

Un répertoire dont le nom commence par _ ou . n'est jamais routé : il peut contenir des composants ou des utilitaires propres à un segment.

Le composant Page

L'export par défaut de page.tsx reçoit :

/** @jsxImportSource @bext-stack/framework */

export default function Page({ params, searchParams, data, actionData }) {
  return <h1>Hello {params.slug}</h1>;
}

searchParams associe chaque paramètre de requête à une chaîne, ou à un tableau de chaînes quand il est répété. La Page ne voit jamais la request brute : seuls loader et action la voient. Lisez-y les cookies et en-têtes, puis transmettez ce dont vous avez besoin via data / actionData.

Routes API

Un route.ts placé sous src/app/api/ répond à l'URL correspondante : src/app/api/posts/route.ts répond à /api/posts, src/app/api/posts/[id]/route.ts à /api/posts/42.

// src/app/api/posts/[id]/route.ts
export async function GET(request, { params }) {
  return { id: params.id };            // envoyé en JSON, statut 200
}

export async function POST(request, { params }) {
  const body = await request.json();
  return new Response(JSON.stringify({ id: params.id, ...body }), {
    status: 201,
    headers: { "content-type": "application/json" },
  });
}

request se lit comme une Request Web : method, url, headers.get(), json(), text(), formData() (urlencoded et multipart) et arrayBuffer().

  • Une méthode sans export reçoit un 405 avec { "error": "method <MÉTHODE> not allowed" }, sauf si le fichier a un export par défaut, qui traite alors toutes les méthodes.
  • Retourner une Response l'envoie telle quelle. Retourner un objet { status, headers, body } construit par les helpers de réponse (voir plus bas) l'envoie aussi. Toute autre valeur part en réponse JSON 200.
  • Une erreur non interceptée devient une réponse JSON 500 { "error": "<message>" }.
  • Une URL sous /api/ qu'aucun route.ts ne couvre renvoie un 404, sans essayer les pages.

Un route.ts placé hors de src/app/api/ (par exemple src/app/flux.xml/route.ts) sert de repli : il ne répond que si aucune page ne correspond à l'URL, et seulement pour les méthodes qu'il exporte. Une autre méthode reçoit un 404, pas un 405. Un route.ts posé à côté d'un page.tsx est ignoré : la page garde l'URL.

Routes dynamiques, catch-all, groupes et parallèles

Motif Signification URL d'exemple
[slug] Segment dynamique → params.slug /posts/hello
[...slug] Catch-all → params.slug, le reste du chemin en une seule chaîne ("a/b/c") /docs/a/b/c
(group) Regroupement uniquement, aucun effet sur les URL des pages (marketing)/posts → /posts
@name/page.tsx Emplacement de route parallèle nommée transmis au layout dans la prop name

La priorité se décide segment par segment, de gauche à droite : un segment littéral passe avant [param], qui passe avant [...rest]. La route qui en dit le plus sur le début de l'URL l'emporte. Un catch-all répond aussi à son propre préfixe (docs/[...slug] répond à /docs avec params.slug === ""), mais seulement quand aucune autre route ne correspond exactement.

La page d'un emplacement parallèle reçoit les mêmes props que la page, est rendue en HTML puis transmise au layout le plus profond dans une prop portant le nom de l'emplacement (pour @modal, le layout lit ({ children, modal })). Un emplacement dont le rendu échoue reste vide.

Les routes d'interception de Next.js ((.)dossier, (..)dossier) ne sont pas prises en charge : les pages qu'elles contiennent ne sont pas routées.

Métadonnées

Ajoutez des balises head avec un export metadata, ou generateMetadata() quand elles dépendent de l'URL :

export const metadata = {
  title: "Posts",
  description: "All posts",
};

// or, dynamic:
export async function generateMetadata({ params, searchParams, route, status }) {
  const post = await getPost(params.slug);
  return { title: post.title };
}

generateMetadata reçoit { params, searchParams, route, status }, pas les données du loader. route vaut { pathname, host } ; status vaut 200 pour un rendu normal, 404 pour le rendu de not-found.tsx et 500 pour celui d'error.tsx.

Les layouts peuvent aussi exporter metadata ou generateMetadata. Les niveaux sont fusionnés du layout racine vers la page : pour chaque clé, le niveau le plus profond l'emporte, et un objet (openGraph, robots…) est remplacé en entier, pas fusionné. title accepte une chaîne ou { default, template, absolute } : un layout qui déclare template: "%s | Mon site" formate les titres des niveaux en dessous. Pour une 404 ou une erreur, seuls les layouts fournissent les métadonnées.

Quand un même fichier exporte les deux, le metadata statique l'emporte. Si generateMetadata lève une erreur, ce niveau est ignoré et la page se rend quand même avec les métadonnées des autres niveaux.

Les fichiers statiques icon.*, apple-icon.*, opengraph-image.* et twitter-image.* placés dans src/app/ (ou un sous-répertoire) sont servis à l'URL correspondante, et la balise <link> ou <meta> associée est ajoutée au head des pages concernées. sitemap.xml et robots.txt à la racine de src/app/ sont servis tels quels.

Modes de rendu

Le mode de rendu du site se définit dans bext.config.toml :

[rendering]
mode = "isr"        # seule la valeur "isr" active le cache de pages
revalidate = 60     # en secondes, 60 si absent
  • Sans mode = "isr", chaque requête est rendue.
  • isr : régénération statique incrémentale. Le rendu est mis en cache et revalidé après revalidate secondes ; pendant une courte fenêtre après expiration, la copie périmée est servie tandis qu'un nouveau rendu s'exécute en arrière-plan.

Sur un site ISR, une route peut fixer sa propre durée de cache avec export const revalidate = 30 (en secondes), et sa fenêtre de copie périmée avec export const swr = 120 (en secondes, pris en compte avec revalidate).

Info

En ISR, les requêtes qui portent un cookie de session ou un en-tête Authorization ne sont ni servies depuis le cache de pages ni stockées dedans : les visiteurs connectés obtiennent toujours un rendu frais.

Indépendamment du mode, le résultat d'un loader est aussi mis en cache jusqu'à 15 secondes pour les requêtes GET sans aucun cookie, quand le loader ne lit aucun en-tête de requête et retourne une valeur sérialisable en JSON. Excluez une route avec force-dynamic (ci-dessous) quand elle doit toujours être à jour, par exemple une page atteinte par un lien à jeton.

Désactiver la mise en cache

export const dynamic = "force-dynamic";
// or
export const revalidate = false;

L'un ou l'autre export exclut la route du cache de pages et du cache automatique des loaders (revalidate = 0 exclut aussi la route du cache des loaders).

Streaming

Une route diffuse son HTML en streaming dès que l'une de ces conditions est vraie :

  • elle importe Suspense depuis @bext-stack/framework/streaming,
  • elle a un loading.tsx à côté de son page.tsx,
  • son export par défaut est un générateur asynchrone (export default async function* Page()), ou
  • elle exporte export const renderingMode = "streaming".

export const renderingMode = "buffered" désactive le streaming pour une route qui serait sinon diffusée. force-dynamic ne désactive pas le streaming, seulement la mise en cache.

Un loader ou une action qui redirige reste respecté : la réponse n'est engagée qu'une fois le loader et l'action exécutés.

Helpers de réponse

Le framework exporte des helpers qui construisent des objets de réponse simples ({ status, headers, body }) :

import { json, html, text, xml, redirect, notFound } from "@bext-stack/framework";
Helper Signature
json json(data, status = 200)
html, text, xml html(body, status = 200), et de même pour text et xml
redirect redirect(location, status = 302), statut parmi 301, 302, 307, 308
notFound notFound(body = "Not Found")

Une route API (route.ts) peut retourner ces objets directement. Ce ne sont pas des instances de Response : dans un loader ou une action, lancez-les (throw redirect("/login")) pour court-circuiter le rendu ; retournés, ils deviennent simplement data ou actionData. Voir Loaders & Actions.

Pour supprimer des entrées en cache par tag, utilisez revalidateTag du point d'entrée cache :

import { revalidateTag } from "@bext-stack/framework/cache";

revalidateTag("products"); // returns the number of entries dropped

Et ensuite

Page Ce qu'elle couvre
Loaders & Actions Chargement de données, mutations, accès à la requête/session
Présentation du SDK Structure d'un site, le client createSdk, l'intégration de packages
Référence CLI bext dev, bext build, bext routes
Référence de configuration bext.config.toml