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

The manifest is also what reaches the browser. The client resolves a route's module through a registry built from the manifest's refs, so a file in src/routes/ or src/shells/ that the manifest never names is not compiled into the client bundle — a draft, a scratch copy, or a route you deleted from the manifest but left on disk stays out of dist/client, and so does a shared module you keep under src/routes/. If a ref could live somewhere the manifest file does not show — you import your routes from another module, or build a specifier at runtime — the registry covers both directories whole instead, since dropping a module a route needs would break navigation to it.

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, WebMCP tools, Markdown capability, revalidation

RouteMeta fields:

Field Type Description
id string Stable route id for typed routes and <Link route>. Generated from the path when omitted
capabilities string[] Registered WebMCP page tools active while this route is current
render "ssr" | "ssg" | "isg" | "spa" Render mode. Defaults to "ssr"
hydration "full" | "islands" | "none" How much of the page hydrates. See Islands
shell string Named shell that wraps this route
middleware string[] Named middleware to run before the loader
prefetch "intent" | "viewport" | "hover" | "none" JS prefetch strategy. Defaults to "intent"
speculation "prefetch" | "prerender" | { mode, eagerness } Browser speculation rules opt-in
revalidate RouteRevalidate timeRevalidate() / webhookRevalidate() for ISG routes
loaderCache LoaderCache Cache-Control policy for this route's loader response
streaming boolean Stream deferred values on full-hydration SSR routes. See Data Loading
markdown boolean Declare that middleware negotiates a Markdown representation for this route

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, streaming, 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 as "*"

route("/files/:path*", "./routes/files.tsx");
// Same match, captured under params.path instead

Reading params

Server-side, matched params arrive on the loader, middleware, and API route args:

src/routes/blog-post.tsx
export async function loader({ params }: LoaderArgs) {
  return { post: await getPost(params.slug) };
}

In a component, useParams() reads the same values from the active route:

import { useParams } from "@pracht/core";

export default function BlogPost() {
  const { slug } = useParams();
  return <article data-slug={slug}>…</article>;
}

It returns {} when no route is active, and re-renders on client-side navigation, so a component shared across several routes can read whichever params the current one matched. Prefer the loader's params when the value is only needed to fetch data — that path runs on the server and needs no hydration.

A catch-all segment is exposed under the key "*":

const { "*": rest } = useParams(); // /docs/a/b/c → "a/b/c"

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: [...],
});

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.

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.

<Link> accepts every anchor attribute — target, rel, download, ping, referrerpolicy, hreflang, class, event handlers — plus:

Prop Type Description
route RouteId Required. The route id to navigate to
params Record<string, unknown> Values for the route's dynamic segments
search object | string Query string to append
hash string Fragment to append
prefetch "intent" | "viewport" | "render" | "none" Override the route's prefetch strategy for this link
speculate boolean Opt this link out of / back into speculation rules
preserveScroll boolean Keep the current scroll position instead of scrolling to the top
viewTransition boolean Wrap this navigation in document.startViewTransition() where supported

<Link> builds its own href from route and params, so passing one is always a mistake — and it used to be a silent one, because the built href overwrote it. It is now a compile error that names the fix:

<Link href="/blog/hello">Read</Link>   // ✗ does not typecheck
<Link route="blog-post" params={{ slug: "hello" }}>Read</Link>  // ✓

Use a plain <a href> for external and user-provided URLs — the client router leaves those alone.

The rule also applies to spreads. A wrapper component that forwards anchor props must not carry href in its own props type, or the spread fails to typecheck:

type ButtonLinkProps = Omit<JSX.IntrinsicElements["a"], "href"> & {
  route: RouteId;
};

function ButtonLink({ route, ...rest }: ButtonLinkProps) {
  return <Link route={route} {...rest} />;
}

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

capabilities also inherits through groups, but unlike scalar settings it is additive: a route keeps the group's names and adds its own, with duplicates removed. Initial hydration registers the matched route's tools; after client navigation commits, pracht replaces them with the destination route's set. hydration: "none" routes cannot activate page tools.

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 supports and how

Auto-discovery replaces the manifest, so everything a manifest registers by name is registered by file instead. The two routers reach the same runtime: the plugin generates a defineApp() manifest from the file system, and every feature below runs through it unchanged.

Feature Pages router
Render and hydration modes, route-scoped WebMCP tools, dynamic/catch-all routes, getStaticPaths, API routes RENDER_MODE / HYDRATION / REVALIDATE / CAPABILITIES exports on the page file
Shells _app.tsx per directory — pages, pages:blog, …. The nearest one wins and replaces its parent
Route middleware one root _middleware.ts on serverful adapters, applied to every page route
Capabilities every module in src/capabilities/ — HTTP endpoints, WebMCP page tools, remote MCP, <Form capability>, typed clients, and pracht eval all work
agents (Web Bot Auth, confirmation, MCP) and constraints named exports from src/pages/_app.config.ts

What still requires an explicit manifest — the things whose whole point is that they differ per route:

  • Per-route middleware assignment. _middleware.ts runs on every page route. Gating only /app/** means ejecting and using group({ middleware: [...] }), or branching on stripBase(url.pathname) inside the one file.
  • Per-route shell overrides. A shell is chosen by directory. One page opting out of its directory's shell needs a manifest.
  • Named middleware beyond the one file, and capabilities registered from outside src/capabilities/.
  • Path prefixes and route idsgroup({ pathPrefix }) and explicit route(..., { id }) have no file-system spelling.
  • Webhook and combined ISG policies. Pages ISG is time-based only.

Ejecting is a one-time codegen, so starting with pages routing does not close any of these off.

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/blog/_app.tsx (shell for /blog/**, not a route)
pages/_middleware.ts (middleware, not a route)
pages/_app.config.ts (app config, not a route)
pages/_anything.tsx (ignored — underscore prefix is reserved)
pages/_components/button.tsx (ignored — the whole directory is reserved)

The underscore prefix reserves both files and directory trees for non-route implementation details. Pracht never creates routes from their contents, so pages/_components/button.tsx is ignored rather than exposed at /_components/button. _app is recognized in any directory outside a reserved tree; _middleware is recognized only at the pages root, and _middleware/ remains a hard error because silently ignoring a directory that looks like an authorization boundary would fail open.

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'" };
}

Directory-scoped shells

An _app in a subdirectory owns every route in that subtree. pages/blog/_app.tsx is registered as "pages:blog" and wraps /blog, /blog/:slug, and everything below it:

src/pages/
  _app.tsx           → shell "pages"      wraps /, /about
  index.tsx
  about.tsx
  blog/
    _app.tsx         → shell "pages:blog" wraps /blog, /blog/:slug
    index.tsx
    [slug].tsx

Shells replace, they do not nest. The nearest _app above a route is the only shell that renders it — blog/_app.tsx does not render inside the root _app.tsx. This is the same rule an explicit manifest follows: resolveApp() gives every route exactly one shell, and a nested group({ shell }) overrides its parent rather than composing with it. A directory shell therefore owns the whole document chrome for its subtree, including its own head() and headers(); copy what it needs from the parent.

pracht inspect routes prints the resolved shell per route (shell=pages:blog), and the ejected manifest names the same shells. One _app per directory: two files that resolve to the same name (blog/_app.tsx and blog/_app.jsx) fail build, doctor, and verify rather than letting one silently win. An _app inside a reserved tree such as pages/_components/_app.tsx stays an ordinary helper.

Per-route shell assignment — one route in a directory opting out of its directory's shell — still requires ejecting to an explicit manifest.

Additional Route Extensions

Custom route and shell formats can opt into discovery with dot-prefixed additionalExtensions values:

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

Middleware via _middleware.ts

With a serverful adapter, a root-level pages/_middleware.ts exports the same MiddlewareFn contract as manifest middleware and runs on every page route. Pure static exports cannot use request middleware:

src/pages/_middleware.ts
import { redirect, stripBase, type MiddlewareFn } from "@pracht/core";

export const middleware: MiddlewareFn = async ({ request, url }, next) => {
  if (stripBase(url.pathname) === "/legacy") return redirect("/about", { request });
  const response = await next();
  response.headers.set("x-request-id", crypto.randomUUID());
  return response;
};

Internally it is registered as a named middleware called "pages" and attached to every page route through the generated manifest, so pracht inspect routes, the dev banner, /_pracht devtools, and the ejected manifest all show it.

Scope and limits:

  • Keep the CLI and Vite plugin compatible. pracht generate middleware --name _middleware verifies that the loaded @pracht/vite-plugin supports pages middleware and asks you to upgrade when it does not. This prevents an independently upgraded CLI from scaffolding an auth boundary that an older plugin would ignore.
  • Page routes only. API routes under src/api are not wrapped — the same independent-by-default behavior an explicit manifest has. Wrap API handlers in higher-order functions instead.
  • Match route paths without the deploy base. url.pathname is the public browser pathname and includes Vite's configured base. Pass it through stripBase() before comparing it with route paths such as /legacy.
  • Root level only, single file. A _middleware.ts inside a subdirectory, a _middleware/ directory, and middleware-shaped files using unsupported page extensions (including Markdown/MDX, .tsrx, and configured custom formats) are hard errors at build, doctor, and verify time — never silently ignored files that look like an auth gate. Per-group middleware requires ejecting to an explicit manifest.
  • Server-only helpers stay server-only. Middleware implementations can live in an underscore-reserved helper such as pages/_server/auth.ts and be imported or re-exported by _middleware.ts. Reserved files and directory trees are excluded from the client route/shell registries, and the dedicated _middleware.ts module becomes empty if client code imports it directly. Helper files still enter a browser bundle if client code imports those files directly.
  • Export names are checked statically; values are checked at runtime. The module must declare a named middleware export. Build, doctor, and verify reject an absent export but do not model its value; value export * declarations are treated as unknown. The request runtime performs the authoritative typeof middleware === "function" check and fails closed when it is not callable.
  • Runs for page rendering and route state. For ssr (the default) and spa routes that is every document and client-side route-state request. ssg and isg documents render at build/revalidation time on a sanitized request (GET, path only — no visitor cookies), and any headers the middleware sets are baked into the static output and replayed for every visitor. Their client-side route-state JSON fetches are separate live requests and still traverse middleware with the visitor request. That can vary the JSON response but cannot protect the already-public static HTML, so cookie- or session-based gating belongs on ssr/spa routes.
  • The module must export middleware; a module that does not fails build, doctor, and verify, and requests to page routes fail closed at runtime.
  • The 404 page renders without middleware — it is a not-found response, not a route.

Like every other _-prefixed file, _middleware.ts never becomes a route.

Capabilities via src/capabilities/

Every module in src/capabilities/ is registered as a capability. The directory is the registry — there is no second place to repeat the name — so a module is reachable exactly when it is in that directory:

src/capabilities/notes-search.ts
import { defineCapability, type CapabilityRunArgs } from "@pracht/capabilities";

export default defineCapability({
  name: "notes.search",
  title: "Search notes",
  description: "Find notes whose title or body matches the query.",
  effect: "read",
  expose: { http: true, mcp: true },
  input: {
    type: "object",
    properties: { query: { type: "string", minLength: 1 } },
    required: ["query"],
    additionalProperties: false,
  },
  output: { type: "object", properties: { notes: { type: "array", items: { type: "object" } } } },
  async run({ input }: CapabilityRunArgs<{ query: string }>) {
    return { notes: await searchNotes(input.query) };
  },
});

pracht generate capability --name notes.search --expose http scaffolds this file, including the name.

Naming. The name comes from defineCapability({ name }). Without one it is the file stem, so src/capabilities/ping.ts registers ping. A declared name must map back to its own file with dots written as hyphens — notes.searchnotes-search.ts — so the file a name resolves to is readable from the name alone. A mismatch, an unusable file name, and two modules claiming the same name are all build, doctor, and verify errors.

Everything downstream is the manifest router's, unchanged: the HTTP endpoint at /api/capabilities/notes/search, the remote MCP projection, pracht eval scenarios, <Form capability>, and the typed client pracht typegen generates. WebMCP activation is route-scoped, so each page that should expose a tool exports an inline list:

src/pages/notes.tsx
export const CAPABILITIES = ["notes.search"];

The export is compiled into the same capabilities route metadata an explicit manifest uses. It must contain only non-empty registered capability names and belongs on a page, not _app.tsx or 404.tsx. It cannot be combined with HYDRATION = "none". Navigating away removes these tools; the destination page then registers its own list.

Capability modules are server-only. They are not routes, they never enter a client bundle, and pracht verify checks the same contract rules a manifest app gets — an exposed capability needs a full contract, and a destructive one still needs the confirmation secret.

App config via _app.config.ts

agents and constraints are app-wide, so they live in one root-level src/pages/_app.config.ts rather than being derived from the file system:

src/pages/_app.config.ts
import type { PrachtAgentsConfig } from "@pracht/core";

export const agents: PrachtAgentsConfig = {
  webBotAuth: { policy: "observe", keys: [{ x: "…", agent: "acme-agent.example" }] },
  confirmation: { ttlSeconds: 120 },
  mcp: {
    serverInfo: { name: "my-app", version: "1.0.0" },
    instructions: "Search and create notes.",
  },
};

The generated manifest passes these to defineApp() verbatim, which is what makes the pages router's agent trust surface identical to a manifest app's.

Three named exports are read — agents, constraints, and notFound — and only those. Routes, shells, middleware, and capabilities stay file-discovered, so the config file cannot quietly redefine them. notFound is a fallback: when pages/404.tsx exists it wins, because it is the more specific declaration.

The file fails closed on every shape that would leave an app looking configured while nothing is registered. A nested copy, an unsupported extension, duplicates, a default export instead of named ones, a module exporting none of the three keys, and export * from … (whose names cannot be read without loading the module) are all build, doctor, and verify errors. Delete the file rather than leaving an empty one.

It is not a route, never enters a client bundle, and pracht inspect agents reports the resulting surface exactly as it does for a manifest app.

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

ISG pages must also export a positive integer time policy:

src/pages/pricing.tsx
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",
  // Defaults to `<pagesDir>/../capabilities`; pass `null` to register none.
  capabilitiesDir: "src/capabilities",
});

The generated manifest carries everything auto-discovery registered: every _app as a named shell, _middleware as the pages middleware, every src/capabilities/ module under its resolved name, and the agents / constraints exports of _app.config.ts as ordinary imports. pages/404.tsx remains the not-found page, so nothing has to be re-declared by hand.

Then remove pagesDir from your pracht config and point the discovery directories at the files the ejected manifest references — the runtime resolves manifest refs through those directory registries, so a manifest pointing outside them fails closed at request time:

pracht({
  appFile: "/src/routes.ts",
  routesDir: "/src/pages", // route files stay in src/pages
  shellsDir: "/src/pages", // _app.tsx
  middlewareDir: "/src/pages", // _middleware.ts
});

Alternatively, move the files into the conventional src/routes, src/shells, and src/middleware directories and update the manifest refs. The generated src/routes.ts is a standard manifest you can customize freely, but keep its exported __PRACHT_EJECTED_PAGES_LAYOUT__ = true marker while it retains pages-router layout semantics. The client build uses that explicit marker to exclude underscore-reserved helpers and strip the dedicated middleware module without guessing from registry syntax, including when registries use computed keys, spreads, or helper variables. Header comments may be edited or removed; retain the exported marker when _app or _middleware moves to a conventional directory too.