Routing
pracht uses a hybrid routing model: route modules live as files by convention, but their wiring — shells, middleware, render modes, and URL patterns — is declared explicitly in a single src/routes.ts manifest.
Route Manifest
The manifest is the central source of truth for your app's routing. Define it in src/routes.ts using defineApp, route, and group:
import { defineApp, group, route, timeRevalidate } from "@pracht/core";
export const app = defineApp({
shells: {
public: "./shells/public.tsx",
app: "./shells/app.tsx",
},
middleware: {
auth: "./middleware/auth.ts",
},
routes: [
group({ shell: "public" }, [
route("/", "./routes/home.tsx", { render: "ssg" }),
route("/pricing", "./routes/pricing.tsx", {
render: "isg",
revalidate: timeRevalidate(3600),
}),
]),
group({ shell: "app", middleware: ["auth"] }, [
route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
route("/settings", "./routes/settings.tsx", { render: "spa" }),
]),
],
});Why explicit over file-based?
File-based routing (Next.js, SvelteKit) couples URL structure to directory structure. This forces awkward nesting for layout groups and makes middleware assignment implicit. pracht's hybrid approach:
- Route modules live in
src/routes/(discoverable by convention) - Route wiring is explicit in
src/routes.ts(auditable, type-checked) - Shells and middleware are named references (reusable across groups)
- URL structure is independent of file system layout
API Reference
defineApp(config)
| Field | Type | Description |
|---|---|---|
| shells | Record<string, string> | Named shell modules — key is the name, value is the file path |
| middleware | Record<string, string> | Named middleware modules |
| routes | (RouteDefinition | GroupDefinition)[] | The route tree |
route(path, file, meta?)
| Param | Type | Description |
|---|---|---|
| path | string | URL pattern, e.g. /blog/:slug |
| file | string | Relative path to the route module |
| meta | RouteMeta | Optional render mode, shell, middleware, Markdown capability, revalidation |
group(meta, routes)
Groups routes with shared configuration. Properties cascade to children; a route's own meta overrides the group's.
| Param | Type | Description |
|---|---|---|
| meta | GroupMeta | Shell, middleware, render mode, pathPrefix to inherit |
| routes | RouteDefinition[] | Routes in this group |
Path Patterns
Static paths
route("/about", "./routes/about.tsx");
// Matches /about exactlyDynamic segments
route("/blog/:slug", "./routes/blog-post.tsx");
// /blog/hello-world → params.slug = "hello-world"
route("/users/:userId/posts/:postId", "./routes/user-post.tsx");
// Multiple dynamic segmentsCatch-all segments
route("/docs/*", "./routes/docs.tsx");
// Matches /docs/a/b/c — catch-all available in paramsNot-Found Page
notFound declares the page rendered — with a 404 status — when a request matches no route:
export const app = defineApp({
shells: { public: () => import("./shells/public.tsx") },
notFound: {
component: () => import("./routes/not-found.tsx"),
shell: "public",
},
routes: [...],
});New apps ship with this wired already: create-pracht generates src/routes/not-found.tsx and the matching notFound entry, or src/pages/404.tsx in pages mode. Edit or delete it like any other page.
The shorthand notFound: () => import("./routes/not-found.tsx") takes the module ref directly; the full form also accepts loader, middleware, and hydration. The module is a normal route module — Component, loader, head, headers — and the page hydrates like any other.
It is deliberately not a route. A trailing catch-all (route("/*", ...)) matches every URL, so it shadows static assets and paths you add later, and it shows up in typed routes, prefetching, speculation rules, and SSG path enumeration. notFound sits outside the route table: it runs only after matching fails, and after the adapter has already tried static assets.
It also renders when a loader or middleware throws notFound(), unless the route module exports its own ErrorBoundary. Route-state (JSON) requests and non-GET requests keep their existing 404 behavior, and apps without a notFound page still get a plain-text 404.
In pracht dev, apps that declare a notFound page render it instead of the dev-only route-table 404, so dev matches production.
Typed Routes and Links
Run pracht typegen to generate a type-safe route map from the same resolved app graph used by pracht inspect routes --json:
pracht typegenThis writes src/pracht.d.ts for route id and param types plus src/pracht-routes.ts for an adapter-agnostic href() helper.
import { Link, useNavigate } from "@pracht/core";
import { href } from "../pracht-routes";
export function ProductActions({ id }: { id: string }) {
const navigate = useNavigate();
return (
<>
<Link route="product" params={{ id }} search={{ ref: "home" }}>
View product
</Link>
<button onClick={() => void navigate({ route: "product", params: { id } })}>
Open product
</button>
<a href={href("product", { params: { id }, search: { tab: "details" } })}>
Details
</a>
</>
);
}Explicit id fields are preferred for stable public APIs. Routes without ids use generated ids, and params are inferred from :param, *, and :name* segments. pracht typegen --check is useful in CI to catch stale generated files.
Shells
Shells are Preact layout components that wrap route content. They are decoupled from URL structure — a flat URL like /settings can use the app shell without nesting under /app/settings.
import type { ShellProps } from "@pracht/core";
export function Shell({ children }: ShellProps) {
return (
<div class="app-layout">
<Sidebar />
<main>{children}</main>
</div>
);
}
// Optional: shell-level <head> metadata
export function head() {
return { title: "My App" };
}
// Optional: shell-level document headers
export function headers() {
return { "content-security-policy": "default-src 'self'" };
}Shell head metadata merges with route-level head. Route head takes precedence for title. Arrays like meta and link are concatenated.
Shell document headers merge with route-level headers exports. Route headers take precedence for matching names. These headers apply to HTML document responses, including prerendered SSG/ISG HTML, but not API routes or route-state JSON fetches.
Middleware
Middleware wraps the rest of the request — loaders, API handlers, and inner
middleware — using a next() callback. It can redirect, mutate context,
short-circuit, or wrap the handler in try / catch / finally.
import { redirect, type MiddlewareFn } from "@pracht/core";
export const middleware: MiddlewareFn = async ({ request }, next) => {
const session = await getSession(request);
if (!session) return redirect("/login", { request });
return next();
};Middleware stacks within groups — a route inside a group with ["auth"] that also declares ["rateLimit"] runs both in order. See Middleware for the full guide.
Path Prefix Groups
Groups can add a URL prefix to all child routes, keeping route files flat while grouping URLs logically:
group({ pathPrefix: "/admin", shell: "admin", middleware: ["auth"] }, [
route("/", "./routes/admin/index.tsx"), // → /admin
route("/users", "./routes/admin/users.tsx"), // → /admin/users
route("/settings", "./routes/admin/settings.tsx"), // → /admin/settings
]);Pages Router (Auto-Discovery)
For projects that prefer file-system routing — especially when migrating from Next.js — pracht offers an optional pages-based routing mode. Instead of writing a route manifest, set pagesDir and pracht auto-discovers routes from the file system.
What the pages router does not have
Auto-discovery replaces the manifest — and several features are registered through that manifest, so they are unavailable in pagesDir mode. Read this before choosing a router: create-pracht offers both, and they are not equivalent.
| Feature | Pages router |
|---|---|
Render and hydration modes, dynamic/catch-all routes, getStaticPaths, API routes |
✅ via RENDER_MODE / HYDRATION / REVALIDATE exports |
| Shells | one _app.tsx; no named shells or per-route assignment |
| Route middleware | ❌ no registration seam — wrap API handlers in a higher-order function instead |
| Capabilities | ❌ no capability HTTP endpoints, WebMCP, remote MCP, or pracht eval |
defineApp({ constraints }), agents (Web Bot Auth) |
❌ |
In short: the pages router covers pages, but the runtime agent surface lives on defineApp(). If the app needs any of it, start with the manifest router — or eject to one later, which is a one-time codegen.
The authoring MCP server and generated skills still work in pages mode; they do not add a runtime agent surface.
Setup
import { defineConfig } from "vite";
import { pracht } from "@pracht/vite-plugin";
export default defineConfig({
plugins: [pracht({ pagesDir: "/src/pages" })],
});When pagesDir is set, the appFile option is ignored. The plugin scans the pages directory and generates the route manifest automatically.
File Conventions
| File | Route |
|---|---|
pages/index.tsx |
/ |
pages/about.tsx |
/about |
pages/blog/index.tsx |
/blog |
pages/blog/[slug].tsx |
/blog/:slug |
pages/[...path].tsx |
/* |
pages/_app.tsx |
(shell, not a route) |
pages/_anything.tsx |
(ignored — underscore prefix is reserved) |
Shell via _app.tsx
If pages/_app.tsx exists, it is registered as a shell named "pages" and all discovered routes are automatically wrapped in it:
import type { ShellProps } from "@pracht/core";
export function Shell({ children }: ShellProps) {
return (
<div class="app-layout">
<nav>...</nav>
<main>{children}</main>
</div>
);
}
export function headers() {
return { "content-security-policy": "default-src 'self'" };
}Additional Route Extensions
Custom route and shell formats can opt into discovery with dot-prefixed
additionalExtensions values:
pracht({
pagesDir: "/src/pages",
additionalExtensions: [".vue"],
});This works in both pages and manifest mode. Pracht discovers the files and
applies its route client/server handling; register the format's Vite transform
plugin separately and add an ambient TypeScript module declaration if its
tooling does not provide one. Keep the array inline or in a directly referenced
const so pracht verify and the development type watcher can classify custom
files statically. Dynamic expressions still build through Vite but produce a
verification warning. Vite-scannable component formats participate in initial
dependency scanning automatically; other format plugins must configure Vite's
dependency optimizer themselves.
Configured formats remain conservatively head-bearing because their transform
may synthesize head() from frontmatter or other format-specific metadata.
Client navigation therefore keeps the route-state request for custom modules
even when their raw source appears headless.
Existing .tsrx routes remain discovered without this option for backward
compatibility and retain Pracht's ambient module declaration.
Per-Route Render Mode
Page files can export a RENDER_MODE constant to override the rendering strategy:
export const RENDER_MODE = "ssg";
export default function About() {
return <div>About us</div>;
}Valid values: "ssr" | "ssg" | "isg" | "spa". The default is "ssr", overridable globally via pagesDefaultRender:
pracht({ pagesDir: "/src/pages", pagesDefaultRender: "ssg" });ISG pages must also export a positive integer time policy:
export const RENDER_MODE = "isg";
export const REVALIDATE = 3600;REVALIDATE is a statically analyzable number of seconds. Missing, zero, dynamic, or non-ISG policies fail build, doctor, and verify instead of silently freezing the page. Pages mode supports time revalidation only; webhook or combined policies require ejection to a manifest.
Put the policy on the page route, not _app.tsx or 404.tsx. Declarations inside comments, strings, and Markdown/MDX fenced examples are ignored, while top-level MDX exports work. pagesDefaultRender can be an inline string or a quoted const; more dynamic composition produces a doctor warning and is evaluated authoritatively by the build. Export RENDER_MODE = "isg" next to REVALIDATE when the default cannot be resolved statically.
Route Priority
Routes are sorted: static routes first, then dynamic (:param), then catch-all (*). This matches Next.js resolution order.
404 page
pages/404.tsx becomes the app's not-found page automatically. It is removed from the route table, so — unlike in Next.js — /404 is not a URL of its own.
Ejecting to Explicit Manifest
When you outgrow auto-discovery and want full manifest control, eject with a one-time codegen:
import { generateRoutesFile } from "@pracht/vite-plugin/pages-router";
generateRoutesFile("src/pages", "src/routes.ts", {
pagesDir: "src/pages",
pagesDefaultRender: "ssr",
});Then remove pagesDir from your pracht config. The generated src/routes.ts is a standard manifest you can customize freely.