The application model
Routes
A directory under app/routes/ is a route only when it holds a page.tsx or a page.tera. The path is the directory path.
| Directory | URL |
|---|---|
routes/ | / |
routes/cart/ | /cart |
routes/product/[id]/ | /product/{id} |
routes/files/[...rest]/ | everything under /files/ |
A directory with no page is not a URL. It is just a place to put files.
A page.tera is the same route with a different renderer. Nothing is lowered, bundled or typechecked for it; the host reads the file and renders it, with the loader's return as its context. The loader, the actions and the error boundary beside it are unchanged. A directory can hold one or the other. A directory holding both is refused with an error naming the two files. layout.tera stands in for layout.tsx the same way. Vue, Web Components, htmx and Tera has the details.
The files beside a page
routes/product/[id]/
page.tsx the component
page.loader.ts its only input
actions.ts the mutations it may call
error.tsx what renders when the loader fails
loading.tsx its presence makes the segment streamAdding the file registers it. There is no route table to update and no manifest to edit.
At the top of routes/ two more files apply everywhere: error.tsx for any route without its own and not-found.tsx for a path nothing matches.
Loaders
import type { Ctx } from "@snapfire/fsr";
export async function load({ params, services }: Ctx<"/product/{id}">) {
const product = await services.catalog.get({ id: params.id });
return { name: product.name, price: product.price };
}Four exports are recognised. load produces the page's props. meta produces the title, the description and any head elements. store seeds keys the browser can read. paths belongs to a route with a parameter and names the values that parameter takes, so fsr prerender can write a file for each:
export const paths = () => POSTS.map((p) => ({ slug: p.slug }));It runs at build rather than per request, so it may call a service but may not read the request. A path outside the set still renders live.
The build infers what load returns and writes that type into generated/client.ts as the page's props. The page imports it:
import type { ProductIdProps } from "@generated/client";
export default function Product({ name, price }: ProductIdProps) {
return <h1>{name}</h1>;
}There is no props declaration to keep in sync, because the props type is a projection of the loader. A page that reads a field the loader stopped returning fails the build instead of the request.
What a loader can do
A loader is lowered into IR, so it is a description of how to get data rather than an arbitrary program. It may await services and read params, query, session, identity, locale and now. It may branch, build objects and arrays and use the array methods the lowerer knows. It may not reach for the filesystem, the network directly, a random number or the ambient clock.
When it reaches for something the lowerer cannot hold, the build stops and names the line. That leftover is called residue. The build refuses it on purpose instead of silently falling back to a slow engine.
Layouts
layout.tsx in any routes directory renders around every page under it, with children where the page goes. It has its own layout.loader.ts, independent of the page's.
A page cannot read layout data and a layout cannot read page data. That separation lets a layout keep its DOM and its state across a navigation while the page under it swaps.
A layout is keyed by its module and by the route parameters its loader reads. A layout whose loader reads no parameters stays put across every page beneath it, which suits a header, a sidebar or a nav rail.
Try it
Give a route a loading.tsx and watch the document arrive with the loading module in the page's slot, the real page streaming in behind it. Nothing in the page or the loader changed. The file's presence is what marks the segment as streaming in the plan.