PRISM Data Conventions

PRISM routes are bext components — pure (props) => string (or Promise<string> for async pages). They render server-side via @bext-stack/framework's h() runtime; there is no React, no virtual DOM, no client-side lifecycle. See PRISM for the framework overview.

For pages that need server-side data fetching or mutation handling, PRISM offers Remix-style loader and action conventions — co-locate the data handler with the component in the same route file, and PRISM calls it on the server before the component runs.

Anatomy of a data route

// src/app/posts/[id]/page.tsx

export async function loader({ request, params }: LoaderArgs) {
  const post = await db.post.findUnique({ where: { id: params.id } });
  if (!post) throw new Response("Not Found", { status: 404 });
  return { post };
}

export async function action({ request, params }: ActionArgs) {
  const form = await request.formData();
  await db.post.update({
    where: { id: params.id },
    data: { title: form.get("title") as string },
  });
  return { ok: true };
}

export default function PostPage({ data, actionData }: PageProps) {
  return (
    <article>
      <h1>{data.post.title}</h1>
      {actionData?.ok && <p>Saved.</p>}
      <form method="post">
        <input name="title" defaultValue={data.post.title} />
        <button type="submit">Save</button>
      </form>
    </article>
  );
}

Three exports from the same module:

  • loader — runs for GET/HEAD. Return value is passed as props.data to the component. - action — runs for POST/PUT/DELETE/PATCH. Return value is passed as props.actionData. PRISM re-runs loader after a successful action so the page renders with fresh data, matching Remix semantics. - default — the bext component (returns string or AsyncIterable<string>).

Dispatch rules

Method Has loader Has action What runs
GET / HEAD yes any loader → component
GET / HEAD no any component only
Non-GET any yes actionloader → component
Non-GET any no component only

The decision happens in Rust, before the render pool is engaged — see PrismDataCall::for_request in crates/bext-core/src/framework/prism_data.rs. If the route exports neither handler, PRISM takes the same no-data code path it always has; the convention is strictly additive.

Detecting exports

PRISM scans route source files at build time for four canonical export shapes:

export async function loader(...) {}
export function loader(...) {}
export const loader = ...
export { loader }

…and the same four for action. The detector is intentionally source-level — no ESM parser, just substring matches — because route files are small and false positives are harmless (the runtime falls through when the export doesn't actually exist).

One known false-negative: export { something as loader }. If you rename an identifier inside an export list, the detector won't notice. Use one of the four canonical shapes above.

Throwing Responses

Either handler can throw new Response(...) to short-circuit rendering. PRISM catches the thrown response and returns it verbatim. Use this for 404s, redirects, and auth gates without polluting component code:

export async function loader({ request }: LoaderArgs) {
  const session = await getSession(request);
  if (!session.user) {
    throw new Response(null, {
      status: 302,
      headers: { Location: "/login" },
    });
  }
  return { user: session.user };
}

TypeScript

PRISM exports LoaderArgs, ActionArgs, and PageProps from @bext-stack/framework. The PageProps generic infers data and actionData from the sibling loader / action return types, so you get end-to-end inference without explicit annotations:



export async function loader({ params }: LoaderArgs) {
  return { post: await load(params.id) };
}

export default function Page({ data }: PageProps<typeof loader>) {
  // data.post inferred
}

Caveats

  • Not yet wired into the render pool. As of E7, the detection and dispatch logic are live in bext-core with unit coverage, but the V8 / V8 render path still calls the component directly. Wiring the intermediate loader / action hop into the render pool lands in the E7 follow-up — same cadence as the SolidStart subprocess dispatch. - Runtime-returned JSON is serialised via JSON.stringify. Date, BigInt, and class instances will lose their types. Superjson support is on the roadmap. - No nested routing yet. In Remix, parent routes get their own loader. PRISM's router is flat for now; parent-route loaders arrive when PRISM grows nested layouts beyond the current single-layout model.

Related docs

- PRISM overview — the engine itself

- Routing — how src/app/ maps to URLs

  • SolidStart — same conventions, different framework