Pracht provides a unified data loading model that works across all rendering modes. Loaders fetch data and client hooks provide reactive access.
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.
// src/routes/dashboard.tsx
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
export async function loader({ request, params, context, signal }: LoaderArgs) {
const user = await getUser(request);
const projects = await db.projects.findMany({ userId: user.id });
return { user, projects };
}
export default function Dashboard({ data }: RouteComponentProps<typeof loader>) {
// data is typed as { user: User; projects: Project[] }
return <h1>Welcome, {data.user.name}</h1>;
}The route component can be a function default export or a named Component
export. Named route exports such as loader, head, headers, markdown,
ErrorBoundary, and getStaticPaths remain separate special exports.
A markdown string export opts the route into Markdown-for-Agents content
negotiation: when a request arrives with Accept: text/markdown, the runtime
still executes middleware, the route loader, and document header resolution
first, then returns the raw markdown source with Content-Type: text/markdown
instead of rendering the component. Both the HTML and markdown responses carry
Vary: Accept; routes without a markdown export do not vary on Accept.
| Field | Type | Description |
|---|---|---|
request |
Request |
The incoming Web Request |
params |
RouteParams |
Dynamic URL params (e.g. { slug: "hello" }) |
context |
TContext |
App-level context (from adapter's context factory) |
signal |
AbortSignal |
Cancellation signal for timeouts |
url |
URL |
Parsed URL |
route |
ResolvedRoute |
Matched route metadata |
| 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 route-state fetches; initial HTML stays shell-only |
| Client navigation | Server (fetched as JSON via x-pracht-route-state-request) |
Loaders never run in the browser. This keeps server secrets (DB connections, API keys) safe.
For inline loaders, pracht also loads a client-transformed copy of the route
module in the browser. Server-only route exports such as loader, head,
headers, and getStaticPaths are omitted from that client module, along with
imports that were only referenced by those exports. This stripping happens as a
Vite 8 post-transform pass on Rolldown/Oxc ASTs so it still works after user
plugins turn Markdown/MDX or TypeScript route files into JavaScript.
Framework-generated route-state responses add Vary: x-pracht-route-state-request
so caches keep HTML and JSON variants separate. Those JSON responses also default
to Cache-Control: no-store.
Use loaderCache in route metadata when loader data can safely be reused by the
same browser:
route("/settings", () => import("./routes/settings.tsx"), {
render: "spa",
loaderCache: 3600,
});A positive integer sets Cache-Control: private, max-age=<seconds> on successful
route-state data responses. loaderCache: false and loaderCache: 0 both produce
no-store, which also lets a route opt out of a group default. Redirects and error
responses remain no-store. If a loader returns a Response with an explicit
Cache-Control header, that header takes precedence over route metadata.
loaderCache is client-side browser caching only. It does not change ISG
revalidate, and the prefetcher's bounded 30-second in-memory cache remains
independent so an intent prefetch can still be consumed by the navigation that
immediately follows it. Explicit useRevalidate() calls bypass this browser
cache so user-triggered refreshes and post-mutation reloads still re-run the
loader.
For SPA routes, the initial HTML can still include the matched shell and an
optional shell Loading export so the page is not blank before the route-state
request resolves.
Throw PrachtHttpError for structured error responses:
import { PrachtHttpError } 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 };
}If the route defines an ErrorBoundary, it catches the error and renders
the fallback UI. Otherwise, the sanitized framework/global handler responds.
Export an ErrorBoundary from any route module to catch errors from its loader
or component:
import type { ErrorBoundaryProps } from "@pracht/core";
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, while a shell boundary catches errors from any route inside that shell. If a route has no boundary, the error bubbles up to the shell, then to the global handler.
404s take a different path when the app declares a
notFound page: a route boundary still wins, but the
not-found page takes over from there instead of the shell boundary. "Not
found" is an outcome, not a failure.
Declare one page for "this URL has no content" and pracht uses it for both ways that happens — an unmatched URL, and a loader that cannot find what it was asked for:
// src/routes.ts
export const app = defineApp({
shells: { public: () => import("./shells/public.tsx") },
notFound: {
component: () => import("./routes/not-found.tsx"),
shell: "public",
},
routes: [
// ... your routes
],
});// src/routes/not-found.tsx
import { useLocation } from "@pracht/core";
export function Component() {
const location = useLocation();
return (
<div>
<h1>404</h1>
<p>No page lives at {location.pathname}.</p>
<a href="/">Go home</a>
</div>
);
}Inside a loader or middleware, throw notFound() — sugar for
new PrachtHttpError(404, message):
import { notFound } from "@pracht/core";
export async function loader({ params }: LoaderArgs) {
const post = await getPost(params.slug);
if (!post) throw notFound("Post not found");
return { post };
}The response is the notFound page with a 404 status — unless the route
module exports its own ErrorBoundary, which stays the most specific handler
and wins for that route. Shell-level error boundaries do not intercept 404s
once notFound is configured; keep them for real failures.
notFound is deliberately not a route: it never matches a URL, so it cannot
shadow static assets the way a trailing route("/*", ...) does. See
ROUTING.md for the full configuration and the
exact matrix of when it renders.
During a client-side navigation to a route whose loader throws a 404, the router falls back to a full document load so the server can render the not-found page with the correct status.
Unexpected 5xx errors are sanitized by default in both SSR HTML and
x-pracht-route-state-request JSON responses, including the hydration payload.
Throw PrachtHttpError for expected client-facing failures; 4xx messages stay
intact. If you need raw server error details while debugging, pass
debugErrors: true to handlePrachtRequest(). For safety, debugErrors is
ignored when NODE_ENV=production. When debug errors are enabled outside
production, serialized route and API failures also include error.diagnostics
with framework metadata such as phase, routeId, routePath, routeFile,
loaderFile, shellFile, middlewareFiles, and status.
In dev, uncaught server errors render pracht's full-page error overlay
instead of a bare 500. Stack frames from your app code (and the reported
file path) are clickable — they open the file at the exact line and column
in your editor through Vite's built-in /__open-in-editor endpoint. Frames
from node_modules and Node internals are de-emphasized.
Manifest wiring mistakes fail loudly with a "did you mean" hint. Referencing
an unregistered shell or middleware name (including api.middleware) throws
during resolveApp(), and unknown route ids throw from href()/<Link route>:
Unknown shell "pubic" for route "/". Did you mean "public"? Registered shells: public, app.
Unknown middleware "auht" for route "/admin". Did you mean "auth"? Registered middleware: auth, logging.
Unknown pracht route id "prduct". Did you mean "product"? Registered route ids: home, product, docs.
These messages flow into the dev error overlay as soon as the dev server loads the manifest.
The head export controls <head> content per route:
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 },
],
link: [{ rel: "canonical", href: `https://example.com/blog/${data.post.slug}` }],
};
}Head metadata merges with the shell's head. Route-level values override shell
values for title. Arrays (meta, link) are concatenated.
Use the meta array to set Open Graph and other SEO tags. The head export
has full access to loader data, so these 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 },
// Open Graph
{ 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}` },
// Twitter Card
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: data.product.name },
{ name: "twitter:description", content: data.product.description },
{ name: "twitter:image", content: data.product.imageUrl },
],
link: [
{ rel: "canonical", href: `https://mystore.com/products/${data.product.slug}` },
],
};
}For structured data, include a script entry with type: "application/ld+json":
export function head({ data }: HeadArgs<typeof loader>) {
return {
title: data.article.title,
meta: [
{ property: "og:type", content: "article" },
{ property: "og:title", content: data.article.title },
],
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 },
}),
},
],
};
}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: [
{ name: "description", content: "Default site description" },
{ property: "og:site_name", content: "My Site" },
],
link: [
{ rel: "icon", href: "/favicon.svg" },
],
};
}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.
SSG/ISG document headers are written into the prerender header manifest so
adapters can replay them for static HTML. Pracht rejects dangerous headers such
as Set-Cookie, Authorization, Proxy-Authenticate, WWW-Authenticate, and
secret-shaped custom x-* headers during SSG/ISG prerendering because those
values would become public static output or be replayed across visitors. Use API
routes, middleware Responses, or SSR-only routes for cookies and
authentication headers.
See docs/CSP.md for a Content Security Policy recipe.
Access the current route's loader data reactively. Updates on navigation and revalidation.
If your project runs pracht typegen, pass the route id and the data type is
inferred from that route's loader — no generic needed:
export function Component() {
const data = useRouteData("dashboard");
// data is typed as the awaited return type of the dashboard route's loader
return <span>{data.user.name}</span>;
}Route ids autocomplete against the generated route map. The generated
declaration points at the route module (or the separate loader module wired via
the manifest), so changing a loader's return type flows through without
re-running typegen; only adding, removing, or renaming routes requires a
regeneration. Routes without a loader type their data as undefined. In
development, pracht logs a warning when the id you pass is not the active
route, since the hook always returns the active route's data.
For projects that do not run typegen, pass the loader type explicitly as a generic instead:
export function Component() {
const data = useRouteData<typeof loader>();
return <span>{data.user.name}</span>;
}Imperatively re-run the current route's loader:
export function Component() {
const revalidate = useRevalidate();
return <button onClick={() => revalidate()}>Refresh</button>;
}Reactive pending state for the current client navigation or <Form>
submission:
import { useNavigation } from "@pracht/core";
const navigation = useNavigation();
// { state: "idle" }
// { state: "loading", location } — a navigation is fetching/committing
// { state: "submitting", location, formData } — a <Form> submission is in flightstate—"idle","loading", or"submitting".location— the target{ pathname, search, hash, href }while not idle.formData— the submittedFormDatawhile a<Form>submission is pending.
The hook updates through the router's full lifecycle: navigation start →
route-state fetch → DOM commit → idle. During SSR it always returns
{ state: "idle" }.
Because useNavigation() works from any component (a shell is the natural
place), a top-of-page progress indicator is a few lines:
// src/shells/root.tsx
import { useNavigation } from "@pracht/core";
function NavigationProgress() {
const navigation = useNavigation();
if (navigation.state === "idle") return null;
return <div class="nav-progress" role="progressbar" aria-label="Loading page" />;
}
export function Shell({ children }: ShellProps) {
return (
<>
<NavigationProgress />
{children}
</>
);
}.nav-progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 100%;
background: linear-gradient(to right, #7c3aed, #db2777);
animation: nav-progress-slide 1s ease-in-out infinite;
}function SubmitButton() {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
return (
<button type="submit" disabled={pending}>
{pending ? "Saving…" : "Save"}
</button>
);
}While a <Form> submission is pending, navigation.formData holds the values
the user just submitted — render them immediately instead of waiting for the
loader to revalidate:
export function Component({ data }: RouteComponentProps<typeof loader>) {
const navigation = useNavigation();
const optimisticTitle =
navigation.state === "submitting" ? navigation.formData.get("title") : null;
return (
<ul>
{data.todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
{optimisticTitle ? <li class="pending">{String(optimisticTitle)}</li> : null}
</ul>
);
}Declarative form submission:
import { Form } from "@pracht/core";
export function Component() {
return (
<Form method="post" action="/api/projects">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}The <Form> component:
- Intercepts submit and sends via fetch to the specified action URL (no full page reload)
- Handles redirects automatically
- Falls back to native form submission if JavaScript fails
- Publishes its pending state through
useNavigation()—state: "submitting"with the submittedFormData— for pending buttons and optimistic UI
Standalone server endpoints for REST APIs, webhooks, and health checks:
// src/api/health.ts
export function GET() {
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" },
});
}
// src/api/users/[id].ts
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 });
}If you want to own method dispatch, export one default handler and branch on
request.method:
// src/api/users/[id].ts
import type { ApiRouteArgs } from "@pracht/core";
export default async function handler({ params, request, context }: ApiRouteArgs) {
if (request.method === "GET") {
const user = await context.db.users.find(params.id);
if (!user) return new Response("Not found", { status: 404 });
return Response.json(user);
}
if (request.method === "DELETE") {
await context.db.users.delete(params.id);
return new Response(null, { status: 204 });
}
return new Response("Method not allowed", { status: 405 });
}API routes:
- Live in
src/api/with file-based path mapping - Support dynamic params (
src/api/users/[id].ts→/api/users/:id) and catch-alls (src/api/files/[...path].ts→/api/files/*, accessible viaparams["*"]) - Export named HTTP method handlers (
GET,POST,PUT,PATCH,DELETE) or one default handler that branches onargs.request.method - Return
Responseobjects directly - Share the same request context shape as page routes
- Can opt into app-level API middleware via
defineApp({ api: { middleware } }) - Are excluded from client bundles entirely
For request validation with Standard Schema (zod, valibot, …) and an
end-to-end typed fetch client, wrap handlers with defineApi() — see
docs/API_VALIDATION.md.