Pages and layouts
Turn the file tree into typed routes, nested application shells, and route-specific loading and error states.
Create a functional page
A page module describes one public route. The default function renders resolved page props, while optional named exports such as load, pending, error, action, middleware, head, and rendering or caching exports add route behavior. The file path remains the source of the public URL.
Create src/pages/projects/[id].tsx
tsximport type { PageLoadContext, PageProps } from "@tavojs/core/router";
import { Page, Text } from "@tavojs/ui";
type Project = { id: string; name: string };
type ProjectParams = { id: string };
export async function load({
params,
signal,
url,
}: PageLoadContext): Promise<Project> {
const response = await fetch(
new URL(`/api/projects/${encodeURIComponent(params.id)}`, url),
{ signal },
);
if (!response.ok) throw new Error("Project could not be loaded");
return response.json() as Promise<Project>;
}
export default function ProjectPage({
data,
}: PageProps<Project, ProjectParams>) {
return (
<Page>
<Text as="h1" variant="h1">
{data?.name}
</Text>
</Page>
);
}
Add route-aware typing when it helps
defineRoutePage is an optional helper for keeping the loader, page component, and other route behaviors in one typed object. Its path literal infers dynamic parameters such as id and connects the loader data type to the default page.
The file tree still owns routing. The helper does not register or rename a route, so keep its path literal aligned with the page filename and confirm the result with npx tavo routes.
Create src/pages/projects/[id].tsx
tsximport { defineRoutePage } from "@tavojs/core/router";
import { Page, Text } from "@tavojs/ui";
type Project = { id: string; name: string };
export default defineRoutePage<"/projects/[id]", Project>("/projects/[id]", {
load: async ({ params, signal, url }) => {
const response = await fetch(
new URL(`/api/projects/${encodeURIComponent(params.id)}`, url),
{ signal },
);
if (!response.ok) throw new Error("Project could not be loaded");
return response.json() as Promise<Project>;
},
default: function ProjectPage({ data }) {
return (
<Page>
<Text as="h1" variant="h1">
{data?.name}
</Text>
</Page>
);
},
});
Share UI with layouts
A _layout.tsx file wraps every descendant page. Layouts compose from the root toward the leaf, so the root can own global navigation while a projects layout owns project-specific navigation and shared data.
Create src/pages/projects/_layout.tsx
tsximport type { Child } from "@tavojs/core";
import { Link } from "@tavojs/core/router";
import { Box, Stack } from "@tavojs/ui";
export default function ProjectsLayout({ children }: { children?: Child }) {
return (
<Box padding="lg">
<Stack gap="lg">
<Link to="/projects">Projects</Link>
{children}
</Stack>
</Box>
);
}
Add route-specific loading and error UI
Export pending when a client navigation should replace the previous page with immediate route-specific feedback while the page loader runs. Export error when that page should own a contextual loader-failure view. Both exports are normal Tavo.js components and can be functions or components created with createTavo().
On client navigation, Tavo.js resolves middleware and layout loaders first, renders pending inside the matched layouts, and then runs the page loader. The completed default page and its controller do not mount until the loader succeeds.
PagePendingPropsprovides the target pathname, params, resolved layout layers, and layer data. Page loader data is unavailable because it is still loading.PageErrorPropsaddsdataanderrorfor the failed route. Present a safe message instead of rendering raw server details.Use
aria-busy="true"and a useful accessible label for pending content. Userole="alert"or an equivalent announcement strategy for errors.
Create src/pages/dashboard.tsx
tsximport type {
PageErrorProps,
PageLoadContext,
PagePendingProps,
PageProps,
} from "@tavojs/core/router";
import { Page, Stack, Text } from "@tavojs/ui";
type DashboardData = {
projects: Array<{ id: string; name: string }>;
summary: { active: number };
};
export function pending({ pathname }: PagePendingProps) {
return (
<Page aria-busy="true" aria-label={`Loading ${pathname}`}>
<Stack gap="md">
<Text as="h1" variant="h1">
Loading dashboard…
</Text>
<Text color="muted">Preparing projects and their summary.</Text>
</Stack>
</Page>
);
}
export function error({ pathname }: PageErrorProps) {
return (
<Page>
<Stack gap="md" role="alert">
<Text as="h1" variant="h1">
Could not load the dashboard
</Text>
<Text color="muted">
The data for {pathname} is unavailable. Try again.
</Text>
</Stack>
</Page>
);
}
export async function load({
signal,
url,
}: PageLoadContext): Promise<DashboardData> {
const [projectsResponse, summaryResponse] = await Promise.all([
fetch(new URL("/api/projects", url), { signal }),
fetch(new URL("/api/projects/summary", url), { signal }),
]);
if (!projectsResponse.ok || !summaryResponse.ok) {
throw new Error("Dashboard data could not be loaded");
}
return {
projects: await projectsResponse.json(),
summary: await summaryResponse.json(),
} as DashboardData;
}
export default function DashboardPage({ data }: PageProps<DashboardData>) {
return (
<Page>
<Stack gap="md">
<Text>Active projects: {data?.summary.active ?? 0}</Text>
{data?.projects.map((project) => (
<Text key={project.id}>{project.name}</Text>
))}
</Stack>
</Page>
);
}
Use the route shape that matches the URL
Generate route types with a production build before relying on newly added dynamic paths throughout the application.
Use
[id]for one required segment.Use
[...slug]for one-or-more catch-all segments.Use
[[...slug]]when the catch-all route also owns its base URL.Use
(group)folders to organize routes or apply alternate layouts without changing the URL.
Provide route-level fallbacks
Add src/pages/404.tsx for URLs that do not match a route. Keep the message clear and provide a path back into the application.
Create src/pages/404.tsx
tsximport { Link } from "@tavojs/core/router";
import { Page, Stack, Text } from "@tavojs/ui";
export const head = <title>Page not found</title>;
export default function NotFoundPage() {
return (
<Page>
<Stack gap="md">
<Text as="h1" variant="h1">
Page not found
</Text>
<Text color="muted">
The page may have moved or the address may be incorrect.
</Text>
<Link to="/">Return home</Link>
</Stack>
</Page>
);
}
A page's error export is the first fallback for its page-loader failure. Add src/pages/_error.tsx as the application-wide fallback for routes that do not provide one. Show a stable recovery message without rendering server details, tokens, response bodies, or stack traces.
Create src/pages/_error.tsx
tsximport { Link } from "@tavojs/core/router";
import { Page, Stack, Text } from "@tavojs/ui";
export const head = <title>Something went wrong</title>;
export default function RouteErrorPage() {
return (
<Page>
<Stack gap="md">
<Text as="h1" variant="h1">
We could not load this page
</Text>
<Text color="muted">
Try again, or return home if the problem continues.
</Text>
<Link to="/">Return home</Link>
</Stack>
</Page>
);
}