Route data lifecycle
Follow middleware, layout loaders, page loaders, pending UI, cancellation, and layer data through one route resolution.
Follow the route resolution order
textmatched route
→ runtime-wide middleware
→ plugin middleware
→ root and layout middleware, outer to inner
→ page middleware
→ root and layout loaders, outer to inner
→ page pending UI, only for eligible client navigation
→ page loader
→ layout and page head
→ page or page error, wrapped by resolved layoutsMiddleware may continue or redirect before data work starts. A redirect stops normal route resolution.
Layout loaders run sequentially from outermost to innermost. The page loader runs last.
Pending renders only during an active client navigation with a client-eligible page loader and no layout-loader error.
SSR, static generation, prefetching, fresh resolved-cache hits, and routes without pending do not render page pending UI.
Use the portable request context
Loaders implement PageLoader and receive one PageLoadContext. The same portable request fields are inherited by PageActionContext.
tstype PageLoadContext = {
pathname: string;
params: Record<string, string>;
request: Request;
rawRequest?: unknown;
url: URL;
headers: Headers;
method: string;
signal: AbortSignal;
layers?: Record<string, unknown>;
};request, url, headers, and signal are portable Fetch APIs and work across supported server and browser execution.
Build application-relative URLs with new URL(path, url), then pass signal to fetch and every abort-aware dependency.
Use
rawRequestonly at an adapter integration boundary. It is not portable application state.A loader runs in both environments unless
defineLoaderselects another runtime. UsedefineServerLoaderfor secrets, databases, server sessions, andHttpOnlycookies.Treat params and request data as untrusted input even when the route pattern constrained their shape.
Distinguish loader layers from component layers
The same resolved data is exposed in two shapes for different jobs. Loader context uses the property name layers for a keyed record of successful earlier layout results. Component props use layers for the ordered diagnostic list and layerData for the keyed record.
| Public contract | Property | Shape | Contents |
|---|---|---|---|
PageLoadContext | layers | Record<string, unknown> | Successful earlier layout results only. |
PageProps / PagePendingProps / PageErrorProps | layers | RouteDataLayer[] | Ordered layout layers and, after completion, the page layer; each has id, kind, data, and error. |
PageProps / PagePendingProps / PageErrorProps | layerData | Record<string, unknown> | Successful results keyed by layer ID. |
The current root layer ID is _root.
A directory layout ID is its src/pages-relative directory key. The root directory layout is /;
src/pages/projects/_layout.tsxis projects; route-group names remain in the key.The page layer ID is the compiled route path, such as /projects/:id.
A failed loader remains in the ordered layers array but is omitted from the successful
layerDatarecord.
tsximport type {
PageLoadContext,
PageProps
} from "@tavojs/core/router";
import { Page, Text } from "@tavojs/ui";
type Workspace = { id: string };
type Project = { id: string; name: string };
export async function load({
params,
layers,
signal,
url
}: PageLoadContext): Promise<Project> {
const workspace = layers?.projects as Workspace | undefined;
const response = await fetch(
new URL(
`/api/workspaces/${workspace?.id}/projects/${params.id}`,
url
),
{ signal }
);
if (!response.ok) {
throw new Error("Could not load project");
}
return response.json() as Promise<Project>;
}
export default function ProjectPage({
data,
layerData
}: PageProps<Project, { id: string }>) {
const workspace = layerData?.projects as Workspace | undefined;
return (
<Page>
<Text>{workspace?.id}: {data?.name}</Text>
</Page>
);
}Handle failure and cancellation separately
A layout-loader failure is stored on that layout layer. The failed value is not added to downstream
context.layers, descendants continue, and the layout receives its own data and error props.A page-loader failure renders the page's error export when present, then
src/pages/_error.tsx. The response defaults to status 500 unless head selects another status.notFound() from middleware or any loader stops normal output, renders404.tsx, and returns status 404.A superseding navigation aborts obsolete work. Pass signal onward and do not translate
AbortErrorinto user-facing route failure UI.A server-only loader is skipped during browser resolution; design the browser path so it already has the required hydrated or independently fetched data.
tsximport {
notFound,
type PageErrorProps,
type PagePendingProps
} from "@tavojs/core/router";
import { Page, Text } from "@tavojs/ui";
type ProjectParams = { id: string };
export async function load({ params, signal, url }) {
const response = await fetch(
new URL(`/api/projects/${params.id}`, url),
{ signal }
);
if (response.status === 404) {
notFound();
}
if (!response.ok) {
throw new Error("Could not load project");
}
return response.json();
}
export function pending({
params
}: PagePendingProps<ProjectParams>) {
return (
<Page aria-busy="true">
<Text>Loading project {params.id}…</Text>
</Page>
);
}
export function error({
pathname
}: PageErrorProps<ProjectParams>) {
return (
<Page>
<Text role="alert">
Could not load {pathname}.
</Text>
</Page>
);
}Keep middleware decisions small and auditable
A route middleware export implements PageMiddleware. Use defineMiddleware for portable work and defineServerMiddleware when the decision requires server-only state.
tsimport {
defineServerMiddleware
} from "@tavojs/core/router";
export const middleware = defineServerMiddleware(
async ({ request, signal }) => {
signal.throwIfAborted();
const { readAuthenticatedUser } = await import("src/server/auth");
const user = await readAuthenticatedUser(request, { signal });
if (!user) {
return {
redirect: "/sign-in",
status: 302
};
}
}
);Middleware runtime defaults to both. Use
defineServerMiddlewarewhenever the decision reads secrets or server-only credentials.Return nothing to continue. Return redirect to stop resolution; its status defaults to 302.
A status without redirect does not block or replace the route.
External redirects are disabled by default. Keep them disabled for targets derived from request data.
Verify order, layers, and cancellation
bashnpx tavo inspect route /projects/example --json
npx tavo check
npx tavo buildTest successful layout and page loads, each loader failing independently, notFound(), a redirect, and an aborted slow navigation. Assert both response status and the exact visible boundary rather than checking only rendered text.
Look up exact public types
Follow linked API names to their canonical TypeScript declarations and package boundaries.