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
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 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 params as "*"
route("/files/:path*", "./routes/files.tsx");
// Same match, captured under params.path insteadReading params
Server-side, matched params arrive on the loader, middleware, and API route args:
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:
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.
<Link> props
<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 |
href is not a <Link> prop
<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.
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
]);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.tsruns on every page route. Gating only/app/**means ejecting and usinggroup({ middleware: [...] }), or branching onstripBase(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 ids —
group({ pathPrefix })and explicitroute(..., { 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
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:
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].tsxShells 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:
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:
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 _middlewareverifies that the loaded@pracht/vite-pluginsupports 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/apiare 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.pathnameis the public browser pathname and includes Vite's configuredbase. Pass it throughstripBase()before comparing it with route paths such as/legacy. - Root level only, single file. A
_middleware.tsinside 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, andverifytime — 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.tsand 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.tsmodule 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
middlewareexport. Build, doctor, and verify reject an absent export but do not model its value; valueexport *declarations are treated as unknown. The request runtime performs the authoritativetypeof middleware === "function"check and fails closed when it is not callable. - Runs for page rendering and route state. For
ssr(the default) andsparoutes that is every document and client-side route-state request.ssgandisgdocuments 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 onssr/sparoutes. - The module must export
middleware; a module that does not fails build,doctor, andverify, 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:
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.search ↔ notes-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:
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:
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:
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",
// 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.