Skip to content

Commit eb1f5de

Browse files
authored
Merge pull request #229 from JoviDeCroock/JoviDeCroock/not-found-api
Add a first-class not-found page instead of the catch-all workaround
2 parents c795278 + a0b7f97 commit eb1f5de

50 files changed

Lines changed: 1583 additions & 359 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/app-not-found-page.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@pracht/core": minor
3+
"@pracht/vite-plugin": minor
4+
"@pracht/cli": minor
5+
"create-pracht": patch
6+
---
7+
8+
First-class not-found page: `defineApp({ notFound })` and `notFound()`.
9+
10+
Until now the only way to ship a custom 404 was a trailing catch-all route
11+
(`route("/*", ...)`), which matches *every* URL — so it shadows requests for
12+
static assets and paths the app might serve later, shows up in typed routes,
13+
prefetching, speculation rules, and SSG path enumeration, and stops the client
14+
router from ever falling back to a document navigation for an unknown URL.
15+
16+
- `defineApp({ notFound })` accepts a module ref or
17+
`{ component, loader?, shell?, middleware?, hydration? }`. It is **not** a
18+
route: it never participates in matching, so it runs only after matching (and,
19+
on every first-party adapter, static-asset serving) has failed. It renders
20+
through the normal pipeline — loader, shell, `head`, hydration — with a 404
21+
status, and hydrates under a reserved route id.
22+
- `notFound(message?)` returns a `PrachtHttpError(404)` to throw from a loader
23+
or middleware: `if (!post) throw notFound()`. The response is the app's
24+
not-found page unless the route module exports its own `ErrorBoundary`, which
25+
still wins. Shell-level error boundaries no longer intercept 404s once
26+
`notFound` is configured.
27+
- Route-state (JSON) requests, non-GET/HEAD requests, and apps without a
28+
`notFound` page keep their existing 404 behavior.
29+
- Pages router: `pages/404.tsx` is wired as the not-found page automatically and
30+
removed from the route table, so `/404` is not a URL of its own.
31+
- `pracht dev` renders the app's own 404 page (instead of the dev-only route
32+
table) when one is declared, matching production. `pracht inspect routes`,
33+
the dev banner, and the `/_pracht` devtools page now report it.

docs/ADAPTERS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,12 @@ At the runtime level, an adapter also typically needs to:
734734
export a `markdown` source can respond from the framework.
735735
3. **Check for prerendered pages** -- SSG and ISG routes have HTML files on disk.
736736
For ISG, implement staleness checking.
737-
4. **Delegate dynamic requests** to `handlePrachtRequest()` from `pracht`
737+
4. **Delegate dynamic requests** to `handlePrachtRequest()` from `pracht`.
738+
This ordering is what lets `defineApp({ notFound })` stay safe: the
739+
not-found page only renders once matching *and* asset serving have missed,
740+
so it can never shadow a real file. If the platform is configured to answer
741+
misses itself (e.g. Cloudflare's `assets.not_found_handling`), that answer
742+
wins and the app's not-found page never runs.
738743
5. **Convert the Web `Response`** back to the platform's response format
739744
6. **Provide a context factory** -- create app-level context from platform-specific
740745
inputs (env bindings, headers, etc.)

docs/ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,9 @@ Two ergonomics features are built in:
703703
dev-server URLs (`/src/routes/home.tsx`), which are joined onto the
704704
project root the dev middleware passes in (`server.config.root`).
705705
- **"Did you mean" wiring errors.** `resolveApp()` fails loudly when a
706-
route or group references an unknown shell or middleware name (including
707-
`api.middleware`), and `buildHref()` does the same for unknown route ids.
706+
route, group, or the `notFound` page references an unknown shell or
707+
middleware name (including `api.middleware`), and `buildHref()` does the
708+
same for unknown route ids.
708709
The messages include a closest-match suggestion (small internal
709710
edit-distance helper in `name-suggestions.ts`, no dependency) and the
710711
full list of registered names, e.g.

docs/DATA_LOADING.md

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,40 +138,75 @@ Error boundaries compose: a route boundary catches route-level errors, while
138138
a shell boundary catches errors from any route inside that shell. If a route
139139
has no boundary, the error bubbles up to the shell, then to the global handler.
140140

141+
404s take a different path when the app declares a
142+
[`notFound` page](#custom-404-pages): a route boundary still wins, but the
143+
not-found page takes over from there instead of the shell boundary. "Not
144+
found" is an outcome, not a failure.
145+
141146
#### Custom 404 pages
142147

143-
Throw `PrachtHttpError(404)` in a loader to trigger the route's error boundary
144-
with a 404 status. For a catch-all 404 page, add a wildcard route at the end
145-
of your manifest:
148+
Declare one page for "this URL has no content" and pracht uses it for both
149+
ways that happens — an unmatched URL, and a loader that cannot find what it
150+
was asked for:
146151

147152
```typescript
153+
// src/routes.ts
148154
export const app = defineApp({
155+
shells: { public: () => import("./shells/public.tsx") },
156+
notFound: {
157+
component: () => import("./routes/not-found.tsx"),
158+
shell: "public",
159+
},
149160
routes: [
150161
// ... your routes
151-
route("/:path*", () => import("./routes/not-found.tsx"), { render: "ssr" }),
152162
],
153163
});
154164
```
155165

156-
```typescript
166+
```tsx
157167
// src/routes/not-found.tsx
158-
import { PrachtHttpError } from "@pracht/core";
168+
import { useLocation } from "@pracht/core";
159169

160-
export function loader() {
161-
throw new PrachtHttpError(404, "Page not found");
162-
}
170+
export function Component() {
171+
const location = useLocation();
163172

164-
export function ErrorBoundary() {
165173
return (
166174
<div>
167175
<h1>404</h1>
168-
<p>This page doesn't exist.</p>
176+
<p>No page lives at {location.pathname}.</p>
169177
<a href="/">Go home</a>
170178
</div>
171179
);
172180
}
173181
```
174182

183+
Inside a loader or middleware, `throw notFound()` — sugar for
184+
`new PrachtHttpError(404, message)`:
185+
186+
```typescript
187+
import { notFound } from "@pracht/core";
188+
189+
export async function loader({ params }: LoaderArgs) {
190+
const post = await getPost(params.slug);
191+
if (!post) throw notFound("Post not found");
192+
return { post };
193+
}
194+
```
195+
196+
The response is the `notFound` page with a 404 status — unless the route
197+
module exports its own `ErrorBoundary`, which stays the most specific handler
198+
and wins for that route. Shell-level error boundaries do not intercept 404s
199+
once `notFound` is configured; keep them for real failures.
200+
201+
`notFound` is deliberately *not* a route: it never matches a URL, so it cannot
202+
shadow static assets the way a trailing `route("/*", ...)` does. See
203+
[ROUTING.md](ROUTING.md#not-found-page) for the full configuration and the
204+
exact matrix of when it renders.
205+
206+
During a client-side navigation to a route whose loader throws a 404, the
207+
router falls back to a full document load so the server can render the
208+
not-found page with the correct status.
209+
175210
#### Error sanitization
176211

177212
Unexpected 5xx errors are sanitized by default in both SSR HTML and

docs/MCP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ the server from the project directory.
5959

6060
| Tool | Inputs | Returns |
6161
| --------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
62-
| `inspect_routes` | `cwd?` | Resolved page routes: path, id, render mode, hydration mode, prefetch strategy, speculation rules, shell, middleware, loader file. Unset options serialize as `null`. Same as `pracht inspect routes --json`. |
62+
| `inspect_routes` | `cwd?` | Resolved page routes: path, id, render mode, hydration mode, prefetch strategy, speculation rules, shell, middleware, loader file, plus `notFound` (the app-level 404 page, or `null`). Unset options serialize as `null`. Same as `pracht inspect routes --json`. |
6363
| `inspect_api` | `cwd?` | Resolved API routes: endpoint path, source file, exported HTTP methods, `hasDefaultHandler` (default catch-all export). Same as `pracht inspect api --json`. |
6464
| `inspect_build` | `cwd?` | Build metadata: adapter target, client entry URL, CSS/JS manifests (requires a prior `pracht build`). Same as `pracht inspect build --json`. |
6565
| `doctor` | `cwd?` | Wiring diagnostics with per-check status. Same as `pracht doctor --json`. |

docs/REQUEST_FLOWS.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -488,18 +488,32 @@ request arrives
488488
## Error paths
489489

490490
```
491-
SSR / SPA navigation — loader throws PrachtHttpError(404):
491+
SSR / SPA navigation — loader throws notFound() / PrachtHttpError(404):
492492
493493
── GET /blog/missing (route-state) ──►
494494
◄── 404 application/json { error: { status: 404, message: "Not found" } } ──
495495
496-
Client: render ErrorBoundary({ error }) instead of Component
496+
Client: render ErrorBoundary({ error }) instead of Component.
497+
No boundary → full document load, so the server can render the
498+
app's notFound page with a 404 status.
497499
498-
SSR first load — loader throws PrachtHttpError(404):
500+
SSR first load — loader throws notFound() / PrachtHttpError(404):
499501
500502
── GET /blog/missing ──────────────────►
501-
Server: loader throws → render ErrorBoundary to HTML string
502-
◄── 404 text/html (ErrorBoundary HTML with hydration state) ──────────────
503+
Server: loader throws → route ErrorBoundary, else the app's notFound
504+
page (if declared), else the shell boundary / plain text
505+
◄── 404 text/html (with hydration state) ─────────────────────────────────
506+
507+
Unmatched URL — no route and no API route matches:
508+
509+
── GET /nope ──────────────────────────►
510+
(adapter already tried static assets and missed)
511+
Server: no match → render defineApp({ notFound }) with status 404
512+
◄── 404 text/html (notFound page, hydrates under a reserved route id) ────
513+
514+
Without a notFound page: ◄── 404 text/plain "Not found" ──
515+
Route-state request: ◄── 404 application/json { error } ──
516+
Non-GET/HEAD: ◄── 404 text/plain "Not found" ──
503517
504518
Unexpected 5xx errors are sanitized in both HTML and JSON responses by default.
505519
Pass debugErrors: true to handlePrachtRequest() to expose raw error details.

docs/ROUTING.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

186264
Generate 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
677755
automatically 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

e2e/node-build.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,22 @@ test("pracht build emits a deployable Node server entry", async () => {
7979
expect(apiResponse.status).toBe(200);
8080
await expect(apiResponse.json()).resolves.toEqual({ status: "ok" });
8181

82+
// The app's notFound page renders for unmatched URLs, inside its shell.
83+
const notFoundResponse = await fetch(`http://127.0.0.1:${port}/nope`);
84+
expect(notFoundResponse.status).toBe(404);
85+
expect(notFoundResponse.headers.get("content-type")).toContain("text/html");
86+
expect(notFoundResponse.headers.get("x-pracht-shell")).toBe("public");
87+
const notFoundHtml = await notFoundResponse.text();
88+
expect(notFoundHtml).toContain("404 — page not found");
89+
// It is not a route, so it is never prerendered to a path of its own.
90+
expect(existsSync(resolve(exampleDir, "dist/client/nope/index.html"))).toBe(false);
91+
92+
// ...and it never shadows a static asset: the adapter serves those first.
93+
const robotsResponse = await fetch(`http://127.0.0.1:${port}/robots.txt`);
94+
expect(robotsResponse.status).toBe(200);
95+
expect(robotsResponse.headers.get("content-type")).toContain("text/plain");
96+
await expect(robotsResponse.text()).resolves.toContain("User-agent");
97+
8298
const dashboardResponse = await fetch(`http://127.0.0.1:${port}/dashboard`, {
8399
headers: { cookie: "session=1" },
84100
});

e2e/not-found.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// ---------------------------------------------------------------------------
4+
// App-level notFound page (`defineApp({ notFound })`). Runs against the
5+
// cloudflare example, which declares one — so the dev-only 404 page steps
6+
// aside and dev renders exactly what production renders.
7+
//
8+
// The point of the design is what it *cannot* do: the not-found page is not a
9+
// route, so it never matches a URL and therefore can never shadow a static
10+
// asset or a route registered later.
11+
// ---------------------------------------------------------------------------
12+
13+
test("unmatched navigation renders the app notFound page with a 404 status", async ({ page }) => {
14+
const response = await page.goto("/this/page/does/not/exist");
15+
16+
expect(response?.status()).toBe(404);
17+
expect(response?.headers()["content-type"]).toContain("text/html");
18+
await expect(page.locator("#not-found h1")).toContainText("404");
19+
await expect(page.locator("#requested-path")).toHaveText("/this/page/does/not/exist");
20+
// Rendered inside the configured shell, like any other page.
21+
await expect(page.locator(".public-shell header")).toBeVisible();
22+
});
23+
24+
test("the notFound page hydrates and navigates like a normal page", async ({ page }) => {
25+
await page.goto("/nope");
26+
await page.locator("html[data-pracht-hydrated]").waitFor();
27+
28+
await page.click("#not-found a");
29+
await expect(page.locator("h1")).not.toContainText("404");
30+
expect(new URL(page.url()).pathname).toBe("/");
31+
});
32+
33+
test("static assets are served instead of the notFound page", async ({ request }) => {
34+
const response = await request.get("/robots.txt");
35+
36+
expect(response.status()).toBe(200);
37+
expect(await response.text()).toContain("User-agent");
38+
});
39+
40+
test("existing routes are unaffected", async ({ page }) => {
41+
const response = await page.goto("/pricing");
42+
expect(response?.status()).toBe(200);
43+
});
44+
45+
test("route-state requests for unmatched paths stay JSON", async ({ request }) => {
46+
const response = await request.get("/nope", {
47+
headers: { accept: "*/*", "x-pracht-route-state-request": "1" },
48+
});
49+
50+
expect(response.status()).toBe(404);
51+
expect(response.headers()["content-type"]).toContain("application/json");
52+
expect(await response.text()).not.toContain("<h1>");
53+
});

examples/basic/public/robots.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
User-agent: *
2+
Allow: /

0 commit comments

Comments
 (0)