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:

  1. On POST / PUT / PATCH / DELETE, the action runs first.
  2. The loader runs on every method, including after the action, unless the action returned or threw a Response.
  3. 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

Tip

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 Response to send it directly.
  • Throw a Response to abort deeper work and send it.
  • A 404 response renders the closest not-found.tsx inside your layouts, when there is one. Other statuses are sent as they are.
Warning

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 a DELETE), unless the file has a default export, which then receives every method.
  • A plain return value (neither a Response nor the result of a helper such as json() or redirect()) is sent as JSON with status 200.
  • A thrown error becomes a 500 response 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