Data Loading
pracht provides a unified data model that works across all rendering modes. Loaders fetch data on the server, API routes handle mutations, and client hooks give reactive access to route data — all with full TypeScript inference.
Loaders
A loader is an async function exported from a route module. It runs server-side and returns serializable data that flows into the route component.
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader({ request, params, context }: LoaderArgs) {
const user = await getUser(request);
const projects = await context.db.projects.findMany({ userId: user.id });
return { user, projects };
}
export default function Dashboard({ data }: RouteComponentProps<typeof loader>) {
// data is typed: { user: User; projects: Project[] }
return (
<div>
<h1>Welcome, {data.user.name}</h1>
<ul>
{data.projects.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
</div>
);
}The route component can be a function default export or a named Component
export. Named route exports such as loader, head, headers,
ErrorBoundary, and getStaticPaths remain separate special exports.
LoaderArgs
| Field | Type | Description |
|---|---|---|
| request | Request | The incoming Web Request |
| params | RouteParams | Dynamic URL params, e.g. { slug: "hello" } |
| context | TContext | App-level context from the adapter's context factory |
| signal | AbortSignal | Cancellation signal for timeouts |
| url | URL | Parsed URL object |
| route | ResolvedRoute | Matched route metadata |
When loaders run
| Scenario | Loader runs on |
|---|---|
| SSG build | Build machine, once per path |
| SSR request | Server, every request |
| ISG initial | Build machine, then adapter runtime where supported |
| SPA | Server, during client navigation fetch |
| Client navigation | Server (fetched as JSON) |
Loaders never run in the browser. Database connections, API keys, and secrets in loader code stay server-side permanently.
Error handling
Throw PrachtHttpError for structured error responses. Pair it with an ErrorBoundary export to render a fallback UI:
import { PrachtHttpError } from "@pracht/core";
import type { ErrorBoundaryProps } from "@pracht/core";
export async function loader({ params }: LoaderArgs) {
const post = await getPost(params.slug);
if (!post) throw new PrachtHttpError(404, "Post not found");
return { post };
}
export function ErrorBoundary({ error }: ErrorBoundaryProps) {
return (
<div>
<h1>{error.status ?? 500}</h1>
<p>{error.message}</p>
</div>
);
}Error boundaries compose — a route boundary catches route-level errors, a shell boundary catches errors from any route in that shell, and uncaught errors bubble to the global handler.
Custom 404 page
Add a catch-all route at the end of your manifest to handle unmatched URLs:
route("/:path*", "./routes/not-found.tsx", { render: "ssr" })import { PrachtHttpError } from "@pracht/core";
export function loader() {
throw new PrachtHttpError(404, "Page not found");
}
export function ErrorBoundary() {
return (
<div>
<h1>404</h1>
<p>This page doesn't exist.</p>
<a href="/">Go home</a>
</div>
);
}Unexpected 5xx errors are sanitized by default — only PrachtHttpError messages are shown to users. Pass debugErrors: true to handlePrachtRequest() to see full error details during development; it is ignored when NODE_ENV=production.
Head Metadata
The head export controls <head> content for the route. It receives the loader data as its argument:
export function head({ data }: HeadArgs<typeof loader>) {
return {
title: `${data.post.title} — My Blog`,
meta: [
{ name: "description", content: data.post.excerpt },
{ property: "og:title", content: data.post.title },
{ property: "og:image", content: data.post.coverUrl },
],
link: [{ rel: "canonical", href: `https://example.com/blog/${data.post.slug}` }],
};
}SEO & Open Graph
Use the meta array to set Open Graph, Twitter Card, and other SEO tags. Because head receives loader data, every tag can be dynamic per page:
export function head({ data }: HeadArgs<typeof loader>) {
return {
title: `${data.product.name} — My Store`,
meta: [
{ name: "description", content: data.product.description },
{ property: "og:title", content: data.product.name },
{ property: "og:description", content: data.product.description },
{ property: "og:image", content: data.product.imageUrl },
{ property: "og:type", content: "product" },
{ property: "og:url", content: `https://mystore.com/products/${data.product.slug}` },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: data.product.name },
{ name: "twitter:image", content: data.product.imageUrl },
],
link: [
{ rel: "canonical", href: `https://mystore.com/products/${data.product.slug}` },
],
};
}Structured data (JSON-LD)
Include a script entry with type: "application/ld+json" for search engine structured data:
export function head({ data }: HeadArgs<typeof loader>) {
return {
title: data.article.title,
meta: [{ property: "og:type", content: "article" }],
script: [
{
type: "application/ld+json",
children: JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
headline: data.article.title,
datePublished: data.article.publishedAt,
author: { "@type": "Person", name: data.article.author },
}),
},
],
};
}Shell-level defaults
Shells can also export head to set site-wide defaults. Route-level title overrides the shell's title; meta and link arrays are concatenated:
// src/shells/public.tsx
export function head() {
return {
title: "My Site",
meta: [{ property: "og:site_name", content: "My Site" }],
link: [{ rel: "icon", href: "/favicon.svg" }],
};
}Document Headers
The headers export controls HTTP headers for the route's document response. It receives the same data-aware arguments as head:
export function headers({ data }: HeadersArgs<typeof loader>) {
return {
"content-security-policy": `default-src 'self'; img-src 'self' ${data.cdnOrigin}`,
};
}Headers merge with the shell's headers export. Route-level headers override shell headers with the same name. They apply to HTML document responses, including prerendered SSG/ISG HTML, but not API routes or route-state JSON fetches.
Client Hooks
useRouteData()
Access the current route's loader data reactively. Updates automatically on navigation and revalidation.
export function Component() {
const data = useRouteData<typeof loader>();
return <span>{data.user.name}</span>;
}useRevalidate()
Imperatively re-run the current route's loader:
export function Component() {
const revalidate = useRevalidate();
return <button onClick={() => revalidate()}>Refresh</button>;
}<Form> Component
Declarative form submission with progressive enhancement. Use the action prop to target an API route:
import { Form } from "@pracht/core";
export function Component() {
return (
<Form method="post" action="/api/projects">
<input name="title" placeholder="Project name" />
<button type="submit">Create</button>
</Form>
);
}The <Form> component intercepts submit and sends via fetch (no full page reload), and falls back to native submission if JavaScript fails.
API Routes
Standalone server endpoints for REST APIs, webhooks, and health checks. Files in src/api/ are auto-discovered and mapped to URLs:
// src/api/health.ts → GET /api/health
// src/api/users/[id].ts → GET /api/users/:id
export async function GET({ params, context }: ApiRouteArgs) {
const user = await context.db.users.find(params.id);
if (!user) return new Response("Not found", { status: 404 });
return Response.json(user);
}
export async function DELETE({ params, context }: ApiRouteArgs) {
await context.db.users.delete(params.id);
return new Response(null, { status: 204 });
}API routes export named HTTP method handlers or one default handler that branches on request.method, return Response objects directly, share the same context system as page routes, and are excluded from client bundles entirely.