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:

src/routes.ts
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, 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 exactly

Dynamic 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 segments

Catch-all segments

route("/docs/*", "./routes/docs.tsx");
// Matches /docs/a/b/c — catch-all available in params

Not-Found Page

notFound declares the page rendered — with a 404 status — when a request matches no route:

src/routes.ts
export const app = defineApp({
  shells: { public: () => import("./shells/public.tsx") },
  notFound: {
    component: () => import("./routes/not-found.tsx"),
    shell: "public",
  },
  routes: [...],
});

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 typegen

This 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.

src/shells/app.tsx
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.

src/middleware/auth.ts
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.

Setup

vite.config.ts
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:

src/pages/_app.tsx
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'" };
}

Per-Route Render Mode

Page files can export a RENDER_MODE constant to override the rendering strategy:

src/pages/about.tsx
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:

vite.config.ts
pracht({ pagesDir: "/src/pages", pagesDefaultRender: "ssg" });

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.