@timber-js/app 0.2.0-alpha.153 → 0.2.0-alpha.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/agent-skill.md +344 -0
  2. package/dist/cli.d.ts +9 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +41 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  7. package/dist/server/internal.js +6 -2
  8. package/dist/server/internal.js.map +1 -1
  9. package/dist/server/pipeline-metadata.d.ts.map +1 -1
  10. package/dist/server/pipeline-phases.d.ts.map +1 -1
  11. package/dist/server/sitemap-handler.d.ts.map +1 -1
  12. package/docs/api/30-api-server.mdx +233 -0
  13. package/docs/api/31-api-client.mdx +161 -0
  14. package/docs/api/32-api-cache.mdx +193 -0
  15. package/docs/api/33-api-search-params.mdx +141 -0
  16. package/docs/api/34-api-config.mdx +189 -0
  17. package/docs/api/35-api-typescript.mdx +170 -0
  18. package/docs/api/36-cli.mdx +143 -0
  19. package/docs/learn/00-introduction.mdx +81 -0
  20. package/docs/learn/01-quick-start.mdx +94 -0
  21. package/docs/learn/02-pages-and-layouts.mdx +138 -0
  22. package/docs/learn/03-fetching-data.mdx +211 -0
  23. package/docs/learn/03b-access-control.mdx +130 -0
  24. package/docs/learn/04-loading-states.mdx +67 -0
  25. package/docs/learn/04b-the-flush-point.mdx +115 -0
  26. package/docs/learn/05-typed-params.mdx +275 -0
  27. package/docs/learn/06-forms-and-actions.mdx +150 -0
  28. package/docs/learn/08-streaming.mdx +146 -0
  29. package/docs/learn/09-caching.mdx +121 -0
  30. package/docs/learn/10-middleware.mdx +156 -0
  31. package/docs/learn/11-error-handling.mdx +147 -0
  32. package/docs/learn/12-client-navigation.mdx +168 -0
  33. package/docs/learn/13-configuration.mdx +117 -0
  34. package/docs/learn/14-deploying.mdx +206 -0
  35. package/docs/more/01-advanced-routing.mdx +139 -0
  36. package/docs/more/02-advanced-forms.mdx +137 -0
  37. package/docs/more/03-coming-from-nextjs.mdx +186 -0
  38. package/docs/more/04-metadata-and-fonts.mdx +193 -0
  39. package/docs/more/04b-mdx.mdx +209 -0
  40. package/docs/more/05-content-collections.mdx +90 -0
  41. package/docs/more/06-instrumentation.mdx +188 -0
  42. package/docs/more/07-security.mdx +123 -0
  43. package/docs/more/40-why-timber.mdx +50 -0
  44. package/docs/more/41-timber-vs-nextjs.mdx +81 -0
  45. package/docs/more/42-timber-vs-others.mdx +68 -0
  46. package/llms.txt +55 -0
  47. package/package.json +6 -2
  48. package/src/cli.ts +62 -3
  49. package/src/client/browser-entry/action-dispatch.ts +14 -0
  50. package/src/server/pipeline-metadata.ts +1 -0
  51. package/src/server/pipeline-phases.ts +4 -1
  52. package/src/server/sitemap-handler.ts +12 -3
@@ -0,0 +1,146 @@
1
+ ---
2
+ title: 'Streaming'
3
+ description: 'Suspense placement, deferSuspenseFor, and the layout footgun.'
4
+ slug: 'streaming'
5
+ ---
6
+
7
+ # Streaming
8
+
9
+ ```ts title="app/schema.ts" id="schema" hidden
10
+ import { defineSchema } from '@timber-js/app/params';
11
+ import { codec } from '@timber-js/app/codec';
12
+
13
+ export default defineSchema({
14
+ segmentParams: {
15
+ '[id]': codec.string,
16
+ },
17
+ });
18
+ ```
19
+
20
+ Streaming lets the server send HTML to the browser in chunks as it becomes ready. timber uses streaming selectively — the shell is sent as a complete block, and only `<Suspense>` boundaries stream in afterward.
21
+
22
+ ## The Shell vs. Suspense Boundaries
23
+
24
+ timber divides your page into two phases:
25
+
26
+ **The shell** (before the [flush point](/docs/the-flush-point)):
27
+ - Layouts, navigation, page structure
28
+ - Primary content that defines the page
29
+ - Determines the HTTP status code
30
+
31
+ **Suspense boundaries** (after the flush):
32
+ - Secondary content wrapped in `<Suspense>`
33
+ - Streams in progressively as data resolves
34
+ - Cannot affect the status code
35
+
36
+ ```tsx title="app/products/[id]/page.tsx" use="schema"
37
+ import { Suspense } from 'react';
38
+ import { SEGMENT_PATH } from './$segment';
39
+ import { deny, getSegmentParams } from '@timber-js/app/server';
40
+
41
+ declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
42
+ declare function ReviewsSkeleton(): React.ReactElement;
43
+ declare function ProductReviews(props: { productId: string }): React.ReactElement;
44
+
45
+ export default async function ProductPage() {
46
+ const { id } = getSegmentParams(SEGMENT_PATH);
47
+ const product = await getProduct(id);
48
+ if (!product) deny(404); // Real HTTP 404
49
+
50
+ return (
51
+ <div>
52
+ <h1>{product.name}</h1>
53
+ <Suspense fallback={<ReviewsSkeleton />}>
54
+ <ProductReviews productId={product.id} />
55
+ </Suspense>
56
+ </div>
57
+ );
58
+ }
59
+ ```
60
+
61
+ The product loads before the flush — if it doesn't exist, the server sends a real 404. Reviews stream in later.
62
+
63
+ ## Choosing What to Stream
64
+
65
+ **Don't stream** (keep in the shell):
66
+ - Content that defines the page — the product, the article, the user profile
67
+ - Content that might produce a 404 or redirect
68
+ - Content that should be visible without JavaScript
69
+
70
+ **Do stream** (wrap in `<Suspense>`):
71
+ - Recommendations, related items, activity feeds
72
+ - Analytics widgets, personalization
73
+ - Anything slow that isn't essential to the page's identity
74
+
75
+ The rule: if missing content means the page shouldn't exist, don't stream it.
76
+
77
+ ## No `loading.tsx`
78
+
79
+ There is no `loading.tsx` convention. timber does not insert Suspense boundaries for you. If you want a loading state, you place `<Suspense>` explicitly. This keeps you in control of what streams and what blocks.
80
+
81
+ ## The Layout Suspense Footgun
82
+
83
+ Wrapping `children` in `<Suspense>` inside a layout surrenders HTTP correctness for every page in that subtree:
84
+
85
+ ```tsx
86
+ // ⚠️ Don't do this
87
+ export default function Layout({ children }: { children: React.ReactNode }) {
88
+ return (
89
+ <Shell>
90
+ <Suspense fallback={<Skeleton />}>{children}</Suspense>
91
+ </Shell>
92
+ );
93
+ }
94
+ ```
95
+
96
+ The framework warns in dev mode when this is detected. For navigation loading states, use `usePendingNavigation()` instead.
97
+
98
+ ## `deferSuspenseFor`
99
+
100
+ Sometimes Suspense content resolves fast enough that you'd rather wait for it than show a fallback. `deferSuspenseFor` holds the SSR stream for a specified duration:
101
+
102
+ ```tsx title="app/products/[id]/page.tsx" use="schema"
103
+ import { Suspense } from 'react';
104
+ import { SEGMENT_PATH } from './$segment';
105
+ import { deny, getSegmentParams } from '@timber-js/app/server';
106
+
107
+ declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
108
+ declare function ProductHeader(props: { product: { name: string } }): React.ReactElement;
109
+ declare function ReviewsSkeleton(): React.ReactElement;
110
+ declare function ProductReviews(props: { productId: string }): React.ReactElement;
111
+
112
+ export const deferSuspenseFor = 200; // milliseconds
113
+
114
+ export default async function ProductPage() {
115
+ const { id } = getSegmentParams(SEGMENT_PATH);
116
+ const product = await getProduct(id);
117
+ if (!product) deny(404);
118
+
119
+ return (
120
+ <div>
121
+ <ProductHeader product={product} />
122
+ <Suspense fallback={<ReviewsSkeleton />}>
123
+ <ProductReviews productId={product.id} />
124
+ </Suspense>
125
+ </div>
126
+ );
127
+ }
128
+ ```
129
+
130
+ If reviews resolve within 200ms, they render inline — no fallback shown. If they take longer, the fallback flushes and reviews stream in when ready.
131
+
132
+ ## Signal Promotion
133
+
134
+ When `deny()` or `redirect()` fires inside a `<Suspense>` boundary:
135
+
136
+ **Before the flush** — the signal is promoted to pre-flush semantics. The framework sends the correct HTTP status code and renders the appropriate status file.
137
+
138
+ **After the flush** — the status code is already committed. The signal triggers an error boundary in the client, and timber adds `<meta name="robots" content="noindex">`.
139
+
140
+ `deferSuspenseFor` extends the window where signals can be promoted.
141
+
142
+ **The rule:** don't rely on Suspense-wrapped content to control the status code. If content must drive the status code, fetch it outside `<Suspense>`. Signal promotion is a safety net, not a guarantee.
143
+
144
+ ## Early Hints (103)
145
+
146
+ Before the flush, timber sends 103 Early Hints with CSS, fonts, and JS URLs. These fire at route-match time — before middleware runs — based on the build manifest. The browser starts fetching resources while the server is still working.
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: 'Caching'
3
+ description: 'Explicit caching with timber.cache — TTL, tags, staleWhileRevalidate, and pluggable backends.'
4
+ slug: 'caching'
5
+ ---
6
+
7
+ # Caching
8
+
9
+ timber.js does not cache anything by default. No patched `fetch`, no implicit data cache. If you want caching, you ask for it explicitly.
10
+
11
+ ## timber.cache vs React.cache
12
+
13
+ This is the number one confusion point. They are different things:
14
+
15
+ | | `timber.cache` | `React.cache` |
16
+ | ------------------- | --------------------------- | -------------------- |
17
+ | Scope | Cross-request (persistent) | Single request |
18
+ | TTL/tags | Yes | No |
19
+ | Works in middleware | Yes | No |
20
+ | Use case | Database queries, API calls | Request-scoped dedup |
21
+
22
+ `React.cache` deduplicates within a single request — if three components call `getUser(id)`, only one database query runs. `timber.cache` persists across requests with TTL — the same query serves cached results for 60 seconds.
23
+
24
+ ```tsx title="lib/data.ts"
25
+ import { cache } from 'react';
26
+ import { cache as timberCache } from '@timber-js/app/cache';
27
+
28
+ declare const db: {
29
+ users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> };
30
+ products: { findPopular(): Promise<{ id: string; name: string }[]> };
31
+ };
32
+
33
+ // Deduplicates within one request
34
+ export const getUser = cache(async (id: string) => {
35
+ return db.users.findUnique({ where: { id } });
36
+ });
37
+
38
+ // Caches across requests for 60 seconds
39
+ export const getPopularProducts = timberCache(
40
+ async () => {
41
+ return db.products.findPopular();
42
+ },
43
+ { ttl: 60, tags: ['products'] }
44
+ );
45
+ ```
46
+
47
+ ## `timber.cache()` Options
48
+
49
+ ```ts
50
+ import { cache } from '@timber-js/app/cache';
51
+
52
+ declare const db: {
53
+ users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> };
54
+ };
55
+
56
+ const getUser = cache(
57
+ async (userId: string) => {
58
+ return db.users.findUnique({ where: { id: userId } });
59
+ },
60
+ {
61
+ ttl: 60, // Cache for 60 seconds
62
+ key: (userId) => `user:${userId}`, // Explicit cache key (optional)
63
+ staleWhileRevalidate: true, // Serve stale while refetching
64
+ tags: (userId) => [`user:${userId}`], // For tag-based invalidation
65
+ }
66
+ );
67
+ ```
68
+
69
+ `tags` accepts a static array (`['products']`) or a function that receives the same arguments as the wrapped function.
70
+
71
+ ## Cache Keys
72
+
73
+ Without an explicit `key`, the cache key is derived from the call site plus a deterministic serialization of the arguments. The call-site identity is content-based — it survives restarts and deploys for unchanged `cache()` calls, and changes when you edit the wrapped function or its options (which invalidates the old entries on persistent handlers like Redis). Object key order doesn't matter — `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same key. `Date`, `URL`, and anything with a `toJSON()` method serialize the way `JSON.stringify` would; `Map` and `Set` are serialized by their entries.
74
+
75
+ Arguments that can't be serialized faithfully — functions, symbols, or class instances whose data lives behind getters with no `toJSON()` — throw a `TypeError` instead of silently colliding. If you hit this, pass an explicit `key`:
76
+
77
+ ```ts
78
+ cache(fn, { ttl: 60, key: (session) => `events:${session.userId}` });
79
+ ```
80
+
81
+ ## Invalidation
82
+
83
+ Invalidate by tag:
84
+
85
+ ```ts
86
+ import { revalidateTag } from '@timber-js/app/server';
87
+
88
+ // In a server action after updating a product:
89
+ revalidateTag('products');
90
+ revalidateTag(`user:${userId}`);
91
+ ```
92
+
93
+ Invalidation wins races against in-flight cached functions: if a cached function is mid-execution when you invalidate one of its tags (the usual mutation flow — update the database, then `revalidateTag`), its result is not written back to the cache, and callers arriving after the invalidation re-execute instead of joining the superseded execution. This guard is per-process; with a shared handler (Redis/KV), an invalidation from a *different* instance can still race a concurrent write on this one.
94
+
95
+ ## `staleWhileRevalidate`
96
+
97
+ When enabled, expired entries are served immediately while a background refetch runs:
98
+
99
+ - **0–60s:** Fresh. Served from cache.
100
+ - **60s+:** Stale. Served immediately, background refetch starts.
101
+ - **Next request after refetch:** Gets fresh data.
102
+
103
+ On serverless platforms (Cloudflare Workers, Lambda), the background refetch is automatically registered with the platform's `waitUntil` so it completes even after the response is sent. If the refetch fails, the stale entry continues to be served and the error is logged.
104
+
105
+ ## Singleflight
106
+
107
+ When multiple concurrent requests miss the cache for the same key, only one executes the underlying function. The rest wait for the result. The result is written to the cache exactly once, by the execution that ran the function. This is built in — no opt-in needed.
108
+
109
+ ## Cache Handlers
110
+
111
+ The default handler is in-memory. Configure a different backend in `timber.config.ts`:
112
+
113
+ ```ts title="timber.config.ts"
114
+ import { MemoryCacheHandler } from '@timber-js/app/cache';
115
+
116
+ export default {
117
+ cacheHandler: new MemoryCacheHandler(),
118
+ };
119
+ ```
120
+
121
+ Implement the `CacheHandler` interface for Redis, Cloudflare KV, or any other backend. See the [Cache API Reference](/docs/api-cache).
@@ -0,0 +1,156 @@
1
+ ---
2
+ title: 'Middleware'
3
+ description: 'proxy.ts for global concerns, middleware.ts for per-segment logic.'
4
+ slug: 'middleware'
5
+ ---
6
+
7
+ # Middleware
8
+
9
+ ```ts title="app/schema.ts" id="schema" hidden
10
+ import { defineSchema } from '@timber-js/app/params';
11
+ import { codec } from '@timber-js/app/codec';
12
+
13
+ export default defineSchema({
14
+ segmentParams: {
15
+ '[userId]': codec.string,
16
+ },
17
+ });
18
+ ```
19
+
20
+ timber.js has two middleware layers: `proxy.ts` for global concerns and `middleware.ts` for per-segment logic.
21
+
22
+ ## Middleware vs Access
23
+
24
+ Before diving in, the first question: should this be middleware or `access.ts`?
25
+
26
+ | | `middleware.ts` | `access.ts` |
27
+ | ------------- | -------------------------------------- | --------------------------------- |
28
+ | Runs | Before rendering | Inside React tree |
29
+ | Purpose | Headers, redirects, cache warming | Auth, data gating |
30
+ | `React.cache` | Not available | Active — shares scope with layout |
31
+ | Slots | N/A | Graceful degradation |
32
+
33
+ Use `middleware.ts` for request-level decisions: should this request proceed? What headers does it need? Use `access.ts` for auth — it has `React.cache` and supports slot degradation.
34
+
35
+ ## `proxy.ts` — Global Middleware
36
+
37
+ `app/proxy.ts` runs on every request before route matching:
38
+
39
+ ```ts title="app/proxy.ts"
40
+ export default [
41
+ cors({ origins: ['https://example.com'] }),
42
+ rateLimit({ requests: 100, window: '1m' }),
43
+ securityHeaders(),
44
+ ];
45
+ ```
46
+
47
+ ```ts title="app/proxy.ts"
48
+ export default async function proxy(req: Request, next: () => Promise<Response>) {
49
+ const start = performance.now();
50
+ const res = await next();
51
+ console.log(`${req.method} ${req.url} — ${performance.now() - start}ms`);
52
+ return res;
53
+ }
54
+ ```
55
+
56
+ `proxy.ts` has `next()` — it can wrap the entire request lifecycle. This is where you observe and modify responses.
57
+
58
+ ## `middleware.ts` — Per-Segment Middleware
59
+
60
+ `middleware.ts` files run after route matching, before rendering. They're scoped to their route segment.
61
+
62
+ ```ts title="app/dashboard/middleware.ts"
63
+ import { redirect } from '@timber-js/app/server';
64
+ import type { MiddlewareContext } from '@timber-js/app/server';
65
+
66
+ declare function getSession(req: Request): Promise<{ userId: string } | null>;
67
+
68
+ export default async function middleware(ctx: MiddlewareContext) {
69
+ const session = await getSession(ctx.req);
70
+ if (!session) redirect('/login');
71
+ }
72
+ ```
73
+
74
+ **Key differences from proxy.ts:** Middleware does not have `next()`. It's a gate — it either short-circuits (redirect, deny, return a Response) or passes through silently. It cannot observe or modify the render result.
75
+
76
+ **Key difference from Next.js:** Next.js has a single global `middleware.ts`. timber lets you place `middleware.ts` at any route segment.
77
+
78
+ ## Middleware Context
79
+
80
+ ```ts
81
+ export default async function middleware(ctx: MiddlewareContext) {
82
+ ctx.req; // The incoming Request
83
+ ctx.segmentParams; // Route params (already coerced by schema.ts)
84
+ ctx.headers; // Response headers (writable)
85
+ ctx.requestHeaders; // Request headers (injectable)
86
+ }
87
+ ```
88
+
89
+ Middleware can inject request headers that components read later:
90
+
91
+ ```ts title="app/middleware.ts"
92
+ import type { MiddlewareContext } from '@timber-js/app/server';
93
+
94
+ declare function detectLocale(req: Request): string;
95
+
96
+ export async function middleware(ctx: MiddlewareContext) {
97
+ const locale = detectLocale(ctx.req);
98
+ ctx.requestHeaders.set('x-locale', locale);
99
+ }
100
+ ```
101
+
102
+ ```tsx title="app/page.tsx"
103
+ import { getHeaders } from '@timber-js/app/server';
104
+
105
+ declare function Content(props: { locale: string | null }): React.ReactElement;
106
+
107
+ export default async function Page() {
108
+ const locale = (getHeaders()).get('x-locale');
109
+ return <Content locale={locale} />;
110
+ }
111
+ ```
112
+
113
+ ## Execution Order
114
+
115
+ For a request to `/dashboard/settings`:
116
+
117
+ 1. `proxy.ts` (global)
118
+ 2. `app/middleware.ts` (root, if exists)
119
+ 3. `app/dashboard/middleware.ts` (segment)
120
+ 4. `app/dashboard/settings/middleware.ts` (segment)
121
+ 5. `access.ts` checks (inside React tree)
122
+ 6. Render
123
+
124
+ ## Cache Warming
125
+
126
+ Use middleware to fire off data fetches before rendering starts:
127
+
128
+ ```ts title="app/users/[userId]/middleware.ts" use="schema"
129
+ import { SEGMENT_PATH } from './$segment';
130
+ import { getSegmentParams } from '@timber-js/app/server';
131
+ import type { MiddlewareContext } from '@timber-js/app/server';
132
+
133
+ declare function getUser(userId: string): Promise<{ id: string; name: string }>;
134
+ declare function getUserPosts(userId: string): Promise<{ id: string; title: string }[]>;
135
+
136
+ export async function middleware(ctx: MiddlewareContext) {
137
+ const { userId } = getSegmentParams(SEGMENT_PATH);
138
+ await Promise.all([
139
+ getUser(userId),
140
+ getUserPosts(userId),
141
+ ]);
142
+ }
143
+ ```
144
+
145
+ ```tsx title="app/users/[userId]/page.tsx" use="schema"
146
+ import { SEGMENT_PATH } from './$segment';
147
+
148
+ export default async function UserPage() {
149
+ const { userId } = getSegmentParams(SEGMENT_PATH);
150
+ const user = await getUser(userId); // cache hit
151
+ const posts = await getUserPosts(userId); // cache hit
152
+ return <Profile user={user} posts={posts} />;
153
+ }
154
+ ```
155
+
156
+ Without cache warming, these fetches waterfall. With middleware, both fire in parallel before rendering begins.
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: 'Error Handling'
3
+ description: 'Status codes, deny(), error boundaries, and status-code files.'
4
+ slug: 'error-handling'
5
+ ---
6
+
7
+ # Error Handling
8
+
9
+ ```ts title="app/schema.ts" id="schema" hidden
10
+ import { defineSchema } from '@timber-js/app/params';
11
+ import { codec } from '@timber-js/app/codec';
12
+
13
+ export default defineSchema({
14
+ segmentParams: {
15
+ '[id]': codec.string,
16
+ },
17
+ });
18
+ ```
19
+
20
+ timber.js distinguishes between two error phases. The phase determines how the error is handled and what HTTP status code the client sees.
21
+
22
+ ## Handler-Phase Errors
23
+
24
+ Errors thrown before rendering (in middleware, access checks, or the page function before JSX) produce a clean HTTP error. No React involved — the server sends the status code and a minimal body.
25
+
26
+ ## Render-Phase Errors
27
+
28
+ Errors thrown during React rendering are caught by error boundaries. Because timber buffers until `onShellReady`, errors outside `<Suspense>` still produce correct HTTP status codes.
29
+
30
+ ## `deny()`
31
+
32
+ `deny()` produces the correct HTTP status and renders the matching status file:
33
+
34
+ ```tsx title="app/products/[id]/page.tsx" use="schema"
35
+ import { SEGMENT_PATH } from './$segment';
36
+ import { deny, getSegmentParams } from '@timber-js/app/server';
37
+
38
+ declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
39
+ declare function canView(product: { id: string }): boolean;
40
+ declare function ProductView(props: { product: { id: string; name: string } }): React.ReactElement;
41
+
42
+ export default async function ProductPage() {
43
+ const { id } = getSegmentParams(SEGMENT_PATH);
44
+ const product = await getProduct(id);
45
+ if (!product) deny(404); // Real HTTP 404
46
+ if (!canView(product)) deny(); // Default: 403
47
+
48
+ return <ProductView product={product} />;
49
+ }
50
+ ```
51
+
52
+ `deny()` called outside `<Suspense>` produces a real HTTP status code. Called inside `<Suspense>` (after the flush), it triggers the nearest error boundary and injects `<meta name="robots" content="noindex">`.
53
+
54
+ ## Status-Code Files
55
+
56
+ Place status-specific error UIs alongside your routes:
57
+
58
+ ```
59
+ app/
60
+ dashboard/
61
+ page.tsx
62
+ error.tsx # Catches any unhandled error
63
+ 404.tsx # "Not found" UI
64
+ 403.tsx # "Forbidden" UI
65
+ 5xx.tsx # Catch-all server errors
66
+ 4xx.tsx # Catch-all client errors
67
+ ```
68
+
69
+ Priority order (innermost to outermost):
70
+
71
+ 1. Specific status files (`403.tsx`, `503.tsx`)
72
+ 2. Category catch-alls (`4xx.tsx`, `5xx.tsx`)
73
+ 3. Next.js-compatible files: `not-found.tsx` (404), `forbidden.tsx` (403), `unauthorized.tsx` (401)
74
+ 4. `error.tsx` — catches anything unmatched
75
+ 5. Parent segments → framework default
76
+
77
+ ## `error.tsx`
78
+
79
+ The general error boundary. It's a client component:
80
+
81
+ ```tsx title="app/dashboard/error.tsx"
82
+ 'use client';
83
+
84
+ export default function DashboardError({
85
+ error,
86
+ reset,
87
+ }: {
88
+ error: Error;
89
+ reset: () => void;
90
+ }) {
91
+ return (
92
+ <div>
93
+ <h2>Something went wrong</h2>
94
+ <p>{error.message}</p>
95
+ <button onClick={reset}>Try again</button>
96
+ </div>
97
+ );
98
+ }
99
+ ```
100
+
101
+ Error boundaries wrap page content inside layouts — the layout shell (nav, sidebar) stays visible when an error fires.
102
+
103
+ When a `deny()` has no matching status-code file, it falls through to the nearest `error.tsx`. The component still receives a real `error` (its message is `Access denied with status <N>`), and additionally receives `status` and `dangerouslyPassData` so you can render denial-specific UI:
104
+
105
+ ```tsx title="app/error.tsx"
106
+ 'use client';
107
+
108
+ export default function ErrorPage({
109
+ error,
110
+ reset,
111
+ status,
112
+ }: {
113
+ error: Error;
114
+ reset: () => void;
115
+ status?: number; // set when this boundary is serving a deny()
116
+ }) {
117
+ if (status === 404) {
118
+ return <h2>Not found</h2>;
119
+ }
120
+ return (
121
+ <div>
122
+ <h2>Something went wrong</h2>
123
+ <p>{error.message}</p>
124
+ <button onClick={reset}>Try again</button>
125
+ </div>
126
+ );
127
+ }
128
+ ```
129
+
130
+ The HTTP status stays correct either way — `deny(404)` falling through to `error.tsx` still responds with a 404, never a 500.
131
+
132
+ ## Structured Error Data
133
+
134
+ `deny()` accepts optional JSON-serializable data that is passed to the error page via `dangerouslyPassData`:
135
+
136
+ ```tsx
137
+ import { deny } from '@timber-js/app/server';
138
+
139
+ if (!product) {
140
+ deny({
141
+ status: 404,
142
+ data: { title: 'Product not found', resourceId: params.id },
143
+ });
144
+ }
145
+ ```
146
+
147
+ The error boundary receives the structured data via the `dangerouslyPassData` prop, surviving the RSC → client boundary where plain error properties are stripped.