Loaders & Actions
A route loads data with a loader and handles mutations with an action. Both are exported
from the page.tsx file alongside the page:
loader: ({ request, params }) => unknown | Promise<unknown>;
action: ({ request, params }) => unknown | Promise<unknown>;
request is a standard Request (headers, json(), formData(), …) and params holds the
route's dynamic segments.
Lifecycle
The order of execution depends on the HTTP method:
- On
POST/PUT/PATCH/DELETE, theactionruns first. - The
loaderruns on every method, including after the action, unless the action returned or threw aResponse. - The page component renders with the results.
The return values flow into the page as props:
| Function | Runs on | Return → prop |
|---|---|---|
loader |
every method | props.data |
action |
mutating methods (POST/PUT/PATCH/DELETE) | props.actionData |
The page also receives params and searchParams. data and actionData are absent when the
matching function does not exist or returned nothing.
Request access and sessions
The page component never sees request: only loader and action do. Read cookies
and headers there. The framework's auth entry point provides a signed cookie session you can
share across your 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) verifies the cookie and returns its payload, or null. To sign in, send
session.cookie(data) as a Set-Cookie header; session.clearCookie() returns the header that
signs out:
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 }) },
});
}
createSession options:
| Option | Default | Purpose |
|---|---|---|
secret |
(required) | HMAC-SHA256 key that signs the cookie |
cookie |
"bext_session" |
Cookie name |
maxAgeSecs |
86400 (1 day) |
Session lifetime and cookie Max-Age |
sameSite |
"Lax" |
"Lax", "Strict" or "None" |
secure |
true |
Pass false to drop the Secure attribute |
path |
"/" |
Cookie Path attribute |
The cookie is always HttpOnly. Pass whatever the page needs down through the loader's return
value; do not try to reach request from the component.
Header-read (vary) tracking
The runtime records which header names the loader and the action read (the "vary set") and
uses it for caching. Read only the headers you actually need, with request.headers.get() or
has(). Reading a broad or per-request header (cookies, auth) makes the response vary on it,
reducing cache hits. Iterating over all headers (forEach, entries(), keys(), a for…of
loop) makes the response vary on every header; touching nothing keeps the route cacheable.
On a GET request without a cookie, the loader's result is also cached for 15 seconds when the
loader read no header (other than cookie). Only plain JSON results are cached (objects, arrays,
strings, numbers, booleans, null): a loader that returns a Map, a Date or a class instance
always runs again. A route that must always be current opts out with
export const dynamic = "force-dynamic" or export const revalidate = false
(see Routing & Rendering).
Short-circuiting the render
Returning or throwing a Response from either function short-circuits the page render
(Remix-style). Use this for redirects and 404s:
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 };
}
- Return a
Responseto send it directly. - Throw a
Responseto abort deeper work and send it. - A
404response renders the closestnot-found.tsxinside your layouts, when there is one. Other statuses are sent as they are.
The redirect() and notFound() helpers from @bext-stack/framework build plain objects, not
Response instances. Thrown (throw redirect("/login")), they short-circuit the render like a
Response. Returned, they end up in props.data (or props.actionData) instead of
redirecting. Note that redirect() defaults to status 302.
Error boundary
An error thrown by a loader, an action or the page render is caught by the closest error.tsx:
/** @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 is always an Error instance (a thrown value that is not one is converted with
String()). route is { pathname, host } of the failing request. The response goes out with
status 500, rendered inside your layouts. The page is rendered on the server, so the reset prop
does nothing there: offer a reload link or button instead. Without an error.tsx, the error
becomes a 5xx response.
Full example: 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>
);
}
API route example (route.ts)
A route.ts under src/app/api/ handles raw HTTP with one function per method, with no page
render. Each handler receives the request and { 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);
}
- A method with no exported handler answers
405(body{ "error": "method DELETE not allowed" }for aDELETE), unless the file has a default export, which then receives every method. - A plain return value (neither a
Responsenor the result of a helper such asjson()orredirect()) is sent as JSON with status200. - A thrown error becomes a
500response with{ "error": "<message>" }.
Where to next
| Page | What it covers |
|---|---|
| Routing & Rendering | Route files, dynamic routes, ISR/SSR, response helpers |
| KV Store | Persist small state a loader can read |
| Entities | Config-driven admin screens and SQL queries |
| Sessions & Tokens | How the Inklura API resolves the session cookie or token |