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 :
- Sur
POST/PUT/PATCH/DELETE, l'actions'exécute en premier. - Le
loaders'exécute pour toutes les méthodes, y compris après l'action, sauf si l'action a retourné ou lancé uneResponse. - 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)
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
Responsepour l'envoyer directement. - Lancez une
Responsepour interrompre un traitement plus profond et l'envoyer. - Une réponse
404rend lenot-found.tsxle plus proche à l'intérieur de vos layouts, s'il existe. Les autres statuts sont envoyés tels quels.
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 unDELETE), 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 commejson()ouredirect()) est envoyée en JSON avec le statut200. - Une erreur levée devient une réponse
500avec{ "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 |