Loaders & Actions

Une route charge ses données avec un loader et gère les mutations avec une action. Les deux sont exportés depuis le fichier page.tsx, aux côtés de la page :

loader:  ({ request, params }) => unknown | Promise<unknown>;
action:  ({ request, params }) => unknown | Promise<unknown>;

request est une Request standard (en-têtes, json(), formData(), …) et params contient les segments dynamiques de la route.

Cycle de vie

L'ordre d'exécution dépend de la méthode HTTP :

  1. Sur POST / PUT / PATCH / DELETE, l'action s'exécute en premier.
  2. Le loader s'exécute pour toutes les méthodes, y compris après l'action, sauf si l'action a retourné ou lancé une Response.
  3. Le composant de page se rend avec les résultats.

Les valeurs de retour arrivent dans la page sous forme de props :

Fonction S'exécute sur Retour → prop
loader toutes les méthodes props.data
action méthodes mutantes (POST/PUT/PATCH/DELETE) props.actionData

La page reçoit aussi params et searchParams. data et actionData sont absents quand la fonction correspondante n'existe pas ou n'a rien retourné.

Accès à la requête et aux sessions

Le composant de page ne voit jamais request : seuls loader et action le voient. Lisez-y les cookies et en-têtes. Le point d'entrée auth du framework fournit une session signée par cookie que vous pouvez partager entre vos routes :

// src/lib/session.ts
import { createSession } from "@bext-stack/framework/auth";

export const session = createSession<{ userId: string }>({
  secret: process.env.SESSION_SECRET!,
  cookie: "myapp_session", // default "bext_session"
});
import { session } from "@/lib/session";

export function loader({ request }) {
  const user = session.read(request); // null when absent, tampered or expired
  return { user };
}

session.read(request) vérifie le cookie et retourne son contenu, ou null. Pour connecter un utilisateur, envoyez session.cookie(data) dans un en-tête Set-Cookie ; session.clearCookie() retourne l'en-tête qui le déconnecte :

export async function action({ request }) {
  const form = await request.formData();
  const userId = await checkCredentials(form); // your own check
  if (!userId) return { error: "Invalid credentials" };
  return new Response(null, {
    status: 303,
    headers: { Location: "/", "Set-Cookie": session.cookie({ userId }) },
  });
}

Options de createSession :

Option Défaut Rôle
secret (obligatoire) Clé de signature HMAC-SHA256 du cookie
cookie "bext_session" Nom du cookie
maxAgeSecs 86400 (1 jour) Durée de validité de la session et Max-Age du cookie
sameSite "Lax" "Lax", "Strict" ou "None"
secure true Passez false pour retirer l'attribut Secure
path "/" Attribut Path du cookie

Le cookie est toujours HttpOnly. Transmettez ce dont la page a besoin via la valeur de retour du loader ; n'essayez pas d'atteindre request depuis le composant.

Suivi des lectures d'en-têtes (vary)

Astuce

Le runtime enregistre quels noms d'en-têtes le loader et l'action lisent (le « vary set ») et s'en sert pour la mise en cache. Ne lisez que les en-têtes dont vous avez réellement besoin, avec request.headers.get() ou has(). Lire un en-tête large ou propre à chaque requête (cookies, auth) fait varier la réponse en fonction de celui-ci, ce qui réduit les hits de cache. Parcourir tous les en-têtes (forEach, entries(), keys(), boucle for…of) fait varier la réponse sur l'ensemble des en-têtes ; ne toucher à rien garde la route cacheable.

Sur une requête GET sans cookie, le résultat du loader est aussi gardé en cache pendant 15 secondes quand le loader n'a lu aucun en-tête (hors cookie). Seuls les résultats JSON simples sont mis en cache (objets, tableaux, chaînes, nombres, booléens, null) : un loader qui retourne une Map, une Date ou une instance de classe est toujours réexécuté. Une route qui doit toujours être à jour s'en exclut avec export const dynamic = "force-dynamic" ou export const revalidate = false (voir Routage & Rendu).

Court-circuiter le rendu

Retourner ou lancer une Response depuis l'une ou l'autre fonction court-circuite le rendu de la page (façon Remix). Utilisez cela pour les redirections et les 404 :

import { session } from "@/lib/session";

export async function loader({ request, params }) {
  if (!session.read(request)) {
    throw new Response(null, { status: 303, headers: { Location: "/login" } });
  }

  const post = await getPost(params.slug);
  if (!post) throw new Response("Not found", { status: 404 });

  return { post };
}
  • Retournez une Response pour l'envoyer directement.
  • Lancez une Response pour interrompre un traitement plus profond et l'envoyer.
  • Une réponse 404 rend le not-found.tsx le plus proche à l'intérieur de vos layouts, s'il existe. Les autres statuts sont envoyés tels quels.
Attention

Les helpers redirect() et notFound() de @bext-stack/framework construisent des objets simples, pas des Response. Lancés (throw redirect("/login")), ils court-circuitent le rendu comme une Response. Retournés, ils atterrissent dans props.data (ou props.actionData) au lieu de rediriger. Notez que redirect() utilise le statut 302 par défaut.

Frontière d'erreur

Une erreur levée par un loader, une action ou le rendu de la page est interceptée par l'error.tsx le plus proche :

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

export default function Error({ error, route }) {
  return (
    <div role="alert">
      <p>Something went wrong: {String(error)}</p>
      <button onclick="location.reload()">Try again</button>
    </div>
  );
}

error est toujours une instance d'Error (une valeur lancée qui n'en est pas une est convertie avec String()). route vaut { pathname, host } de la requête en échec. La réponse part avec le statut 500, rendue à l'intérieur de vos layouts. La page est rendue côté serveur : la prop reset n'y fait rien, proposez plutôt un lien ou un bouton de rechargement. Sans error.tsx, l'erreur devient une réponse 5xx.

Exemple complet : page + loader + action

/** @jsxImportSource @bext-stack/framework */
import { session } from "@/lib/session";
import { createPost, listPosts } from "@/lib/posts";

// Runs first on POST, before the loader.
export async function action({ request }) {
  const user = session.read(request);
  if (!user) throw new Response(null, { status: 303, headers: { Location: "/login" } });

  const form = await request.formData();
  const title = String(form.get("title") ?? "");
  if (!title) return { error: "Title is required" };

  await createPost({ authorId: user.userId, title });
  return { ok: true };
}

// Runs on every method (and after the action).
export async function loader({ request }) {
  return { posts: await listPosts(), signedIn: session.read(request) !== null };
}

export default function Page({ data, actionData }) {
  return (
    <main>
      {actionData?.error && <p role="alert">{actionData.error}</p>}
      <form method="post">
        <input name="title" placeholder="Title" />
        <button type="submit">Create</button>
      </form>
      <ul>{data.posts.map((p) => <li>{p.title}</li>)}</ul>
    </main>
  );
}

Exemple de route API (route.ts)

Un route.ts placé sous src/app/api/ gère le HTTP brut avec une fonction par méthode, sans rendu de page. Chaque handler reçoit la requête et { params } :

// src/app/api/posts/[id]/route.ts
import { json } from "@bext-stack/framework";
import { getPost, updatePost } from "@/lib/posts";

export async function GET(request: Request, { params }) {
  const post = await getPost(params.id);
  return post ? json(post) : json({ error: "not found" }, 404);
}

export async function PUT(request: Request, { params }) {
  const body = await request.json();
  return json(await updatePost(params.id, body), 200);
}
  • Une méthode sans handler exporté répond 405 (corps { "error": "method DELETE not allowed" } pour un DELETE), sauf si le fichier a un export par défaut, qui reçoit alors toutes les méthodes.
  • Une valeur de retour simple (ni une Response, ni le résultat d'un helper comme json() ou redirect()) est envoyée en JSON avec le statut 200.
  • Une erreur levée devient une réponse 500 avec { "error": "<message>" }.

Et ensuite

Page Ce qu'elle couvre
Routage & Rendu Fichiers de route, routes dynamiques, ISR/SSR, helpers de réponse
KV Store Persister un petit état qu'un loader peut lire
Entités Écrans d'administration pilotés par configuration et requêtes SQL
Sessions & Tokens Comment l'API Inklura résout le cookie ou le token de session