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

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" };
}
[!NOTE] Shell head metadata merges with route-level head. Route head takes precedence for `title`. Arrays like `meta` and `link` are concatenated.

Middleware

Middleware runs server-side before the loader. It can redirect, modify context, or throw errors.

src/middleware/auth.ts
import type { MiddlewareFn } from "@pracht/core";

export const middleware: MiddlewareFn = async ({ request }) => {
  const session = await getSession(request);
  if (!session) return { redirect: "/login" };
  // Return void to continue to the loader
};

Middleware stacks within groups โ€” a route inside a group with ["auth"] that also declares ["rateLimit"] runs both in order.

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>
  );
}

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.

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.