Loaders & Actions

A route loads data with a loader and handles mutations with an action. Both are exported from the route file alongside the page:

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

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 a successful action.
  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

Request access and sessions

The page component never sees request — only loader and action do. Read cookies and headers there:

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

export function loader({ request }) {
  const session = readSession(request); // reads the session cookie off request.headers
  return { user: session?.user ?? null };
}

readSession(request) reads the session cookie from request.headers. Pass whatever the page needs down through the return value; do not try to reach request from the component.

Header-read (vary) tracking

Tip

The runtime records which header names a loader reads — the "vary set" — and uses it for caching. Read only the headers you actually need. Reading a broad or per-request header (cookies, auth) makes the response vary on it, reducing cache hits; touching nothing keeps the route cacheable.

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 { redirect, notFound } from "@bext-stack/framework";

export function loader({ request, params }) {
  const session = readSession(request);
  if (!session) throw redirect("/login");

  const post = getPost(params.slug);
  if (!post) throw notFound();

  return { post };
}
  • Return a Response to send it directly.
  • Throw a Response to abort deeper work and send it.

Error boundary

Uncaught errors are caught by the segment's error.tsx:

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

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

reset re-attempts the segment; route describes the failing route.

Full example: page + loader + action

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

// Runs first on POST, before the loader.
export async function action({ request }) {
  const session = readSession(request);
  if (!session) throw redirect("/login");

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

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

// Runs on every method (and after a successful action).
export async function loader({ request }) {
  const session = readSession(request);
  return { posts: await listPosts(), signedIn: Boolean(session) };
}

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>`).join("")}</ul>
    </main>
  );
}

API route example (route.ts)

A route.ts handles raw HTTP with one function per method — no page render:

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

export async function GET(request: Request) {
  return json(await listPosts());
}

export async function POST(request: Request) {
  const body = await request.json();
  const post = await createPost(body);
  return json(post, { status: 201 });
}

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 (Neon) Config-driven CRUD and direct Neon access
Sessions & Tokens How the session cookie / token is resolved