@@ -84,6 +84,7 @@ Top-level configuration:
8484| ` shells ` | ` Record<string, ModuleRef> ` | Named shell modules — use ` () => import("./path") ` for IDE navigation |
8585| ` middleware ` | ` Record<string, ModuleRef> ` | Named middleware modules |
8686| ` routes ` | ` (RouteDefinition \| GroupDefinition)[] ` | Route tree |
87+ | ` notFound ` | ` ModuleRef \| NotFoundConfig ` | Page rendered with a 404 status when nothing matches — see [ Not-Found Page] ( #not-found-page ) |
8788
8889### ` route(path, file, meta?) `
8990
@@ -181,6 +182,83 @@ Matches `/docs/a/b/c` — the catch-all value is available in params.
181182
182183---
183184
185+ ## Not-Found Page
186+
187+ ` defineApp({ notFound }) ` declares the page pracht renders — with a ` 404 `
188+ status — when a request matches no route:
189+
190+ ``` typescript
191+ export const app = defineApp ({
192+ shells: { public : () => import (" ./shells/public.tsx" ) },
193+ notFound: {
194+ component : () => import (" ./routes/not-found.tsx" ),
195+ shell: " public" ,
196+ },
197+ routes: [... ],
198+ });
199+ ```
200+
201+ The shorthand ` notFound: () => import("./routes/not-found.tsx") ` takes the
202+ module ref directly. The full form accepts:
203+
204+ | Field | Type | Description |
205+ | ----------- | ------------------------------- | ------------------------------------------------- |
206+ | ` component ` | ` ModuleRef ` | The page module (must live in the routes directory) |
207+ | ` loader ` | ` ModuleRef ` | Optional separate loader module |
208+ | ` shell ` | ` string ` | Named shell from ` defineApp.shells ` |
209+ | ` middleware ` | ` string[] ` | Named middleware to run for this page |
210+ | ` hydration ` | ` "full" \| "islands" \| "none" ` | Defaults to ` "full" ` , like a route |
211+
212+ The module is a normal route module: ` Component ` /` default ` , an optional
213+ ` loader ` , ` head ` , and ` headers ` all work, and the page hydrates so its links
214+ and event handlers behave like any other page.
215+
216+ ### Why it is not a route
217+
218+ A not-found page defined as a trailing catch-all — ` route("/*", ...) ` — matches
219+ * every* URL. That is the wrong shape for "nothing matched":
220+
221+ - it shadows requests for static assets and any path the app might serve later,
222+ which turns a missing ` /logo.png ` into an HTML page and hides genuine wiring
223+ mistakes;
224+ - it appears in typed routes, prefetching, speculation rules, and SSG path
225+ enumeration, none of which make sense for a 404;
226+ - the client router matches it too, so an unknown URL never falls back to a
227+ document navigation.
228+
229+ ` notFound ` sits outside the route table instead. It never matches a URL, so it
230+ runs only after route matching has failed — and, on every first-party adapter,
231+ after static-asset serving has failed too (Node checks ` staticDir ` first,
232+ Cloudflare asks the ` ASSETS ` binding first, Vercel's ` handle: filesystem `
233+ precedes the function). Existing catch-all routes keep working unchanged; they
234+ simply match before the not-found page is ever considered.
235+
236+ ### When it renders
237+
238+ | Request | Response |
239+ | ---------------------------------------------------- | ----------------------------------------- |
240+ | GET/HEAD document, no route matches | The ` notFound ` page, status 404 |
241+ | A loader or middleware throws ` notFound() ` | The ` notFound ` page, status 404 |
242+ | The matched route exports an ` ErrorBoundary ` | That boundary (most specific wins) |
243+ | Route-state (` x-pracht-route-state-request ` ) request | JSON ` { error: { status: 404 } } ` |
244+ | Non-GET/HEAD request to an unmatched path | Plain-text ` 404 ` |
245+ | No ` notFound ` declared | Plain-text ` 404 ` (unchanged) |
246+
247+ See [ DATA_LOADING.md] ( DATA_LOADING.md#custom-404-pages ) for ` notFound() ` in
248+ loaders and how it interacts with error boundaries.
249+
250+ Because the page is not a route, ` pracht verify ` constraints (which describe
251+ the route graph) do not apply to it, and it is never prerendered to a path of
252+ its own — it is always rendered on demand.
253+
254+ ### Pages router
255+
256+ In ` pagesDir ` mode, ` pages/404.tsx ` is wired as the app's not-found page
257+ automatically. It is removed from the route table, so — unlike in Next.js —
258+ ` /404 ` is not a URL of its own.
259+
260+ ---
261+
184262## Typed Routes and Links
185263
186264Generate a type-safe route map from the same resolved app graph used by
@@ -676,8 +754,11 @@ clickable links. The page is self-contained HTML served by the dev middleware
676754(` @pracht/core/dev-404 ` , same approach as the dev error overlay) and reloads
677755automatically when a route is added.
678756
679- This page exists only in development:
757+ This page exists only in development, and only when the app has no 404 page of
758+ its own:
680759
760+ - Apps that declare [ ` notFound ` ] ( #not-found-page ) own their 404s — dev renders
761+ that page, exactly as production does, and this page never appears.
681762- Route-state (JSON) requests, non-document fetches, and non-GET methods keep
682763 their normal 404 behavior.
683764- Apps that register a catch-all ` route("*", ...) ` match every path, so their
0 commit comments