@timber-js/app 0.2.0-alpha.154 → 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.
- package/agent-skill.md +344 -0
- package/dist/cli.d.ts +9 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +41 -4
- package/dist/cli.js.map +1 -1
- package/docs/api/30-api-server.mdx +233 -0
- package/docs/api/31-api-client.mdx +161 -0
- package/docs/api/32-api-cache.mdx +193 -0
- package/docs/api/33-api-search-params.mdx +141 -0
- package/docs/api/34-api-config.mdx +189 -0
- package/docs/api/35-api-typescript.mdx +170 -0
- package/docs/api/36-cli.mdx +143 -0
- package/docs/learn/00-introduction.mdx +81 -0
- package/docs/learn/01-quick-start.mdx +94 -0
- package/docs/learn/02-pages-and-layouts.mdx +138 -0
- package/docs/learn/03-fetching-data.mdx +211 -0
- package/docs/learn/03b-access-control.mdx +130 -0
- package/docs/learn/04-loading-states.mdx +67 -0
- package/docs/learn/04b-the-flush-point.mdx +115 -0
- package/docs/learn/05-typed-params.mdx +275 -0
- package/docs/learn/06-forms-and-actions.mdx +150 -0
- package/docs/learn/08-streaming.mdx +146 -0
- package/docs/learn/09-caching.mdx +121 -0
- package/docs/learn/10-middleware.mdx +156 -0
- package/docs/learn/11-error-handling.mdx +147 -0
- package/docs/learn/12-client-navigation.mdx +168 -0
- package/docs/learn/13-configuration.mdx +117 -0
- package/docs/learn/14-deploying.mdx +206 -0
- package/docs/more/01-advanced-routing.mdx +139 -0
- package/docs/more/02-advanced-forms.mdx +137 -0
- package/docs/more/03-coming-from-nextjs.mdx +186 -0
- package/docs/more/04-metadata-and-fonts.mdx +193 -0
- package/docs/more/04b-mdx.mdx +209 -0
- package/docs/more/05-content-collections.mdx +90 -0
- package/docs/more/06-instrumentation.mdx +188 -0
- package/docs/more/07-security.mdx +123 -0
- package/docs/more/40-why-timber.mdx +50 -0
- package/docs/more/41-timber-vs-nextjs.mdx +81 -0
- package/docs/more/42-timber-vs-others.mdx +68 -0
- package/llms.txt +55 -0
- package/package.json +6 -2
- package/src/cli.ts +62 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Introduction'
|
|
3
|
+
description: 'Why timber.js exists'
|
|
4
|
+
slug: 'introduction'
|
|
5
|
+
notAI: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Introduction
|
|
9
|
+
|
|
10
|
+
## Basics
|
|
11
|
+
|
|
12
|
+
Timber is a react-based web framework built on vite and server components. It is inspired by next.js, tanstack start, and remix however it brings an independent approach built on a more modern foundation.
|
|
13
|
+
|
|
14
|
+
It is built on web standards and attempts to use as little framework magic as possible. We feel it unlocks both the most flexible UX and DX possible. Rather than using heuristics to adjust how the framework operates, it gives you the knobs to fine-tune your website to your liking.
|
|
15
|
+
|
|
16
|
+
That means it is entirely possible to write an inefficient website with timber, however, you will also _understand_ exactly what is happening and have clear eyes on how to fix it.
|
|
17
|
+
|
|
18
|
+
We believe that the best UX is often invisible, but the best DX is _transparent_. You shouldn't ever be confused about where something is running and why.
|
|
19
|
+
|
|
20
|
+
Timber is not a framework that comes with the simplest mental model – rather it embraces the _inherent complexity_ of the web and gives you the tools to define your boundaries as you see fit. Server and client, static and dynamic, build-time and run-time. So on and so forth. There is no one true _best_ place to put these boundaries, so it is incumbent on the developer to _choose_ where to place them.
|
|
21
|
+
|
|
22
|
+
Timber is a tool for frontend professionals. Though a beginner could pick it up and learn both the framework and the platform (the web) incrementally, it is best utilized by people who have a deep understanding of both the web and building websites.
|
|
23
|
+
|
|
24
|
+
Think of it as a flexible toolbelt of tools: the mental model to understand each of these tools and how to use them is high, but once you do, the framework clicks into place. A simpler mental model may come with it an easier skill curve, but at the sacrifice of both user experience and developer experience.
|
|
25
|
+
|
|
26
|
+
Timber is understandable, but you'll have to be patient in understanding it. We feel it's worth the investment.
|
|
27
|
+
|
|
28
|
+
## Philosophy
|
|
29
|
+
|
|
30
|
+
All web frameworks are created out of some basic philosophies of how its creators feel websites should be built. Timber is no exception.
|
|
31
|
+
|
|
32
|
+
Here's ours:
|
|
33
|
+
|
|
34
|
+
A good web architecture in 2026 can scale up and down with the requirements of the project. That doesn't mean a simple target (e.g. a static blog) comes with it the simplest mental model, but that it can be functionally deployed in the most efficient manner. And that same architecture can scale up to a complex, dynamic website, both from a structural and a deployment perspective.
|
|
35
|
+
|
|
36
|
+
A good web architecture _embraces_ the complexity of building websites. Since building a website is inherently complex, a framework that embraces that complexity will feel complex and overwhelming at first. But it is easier to imagine it as a toolbelt that gives you access to each tool in the belt.
|
|
37
|
+
|
|
38
|
+
So while a beginner could pick up this framework, and it could incrementally teach them each component of building a powerful website, this framework is _not_ designed to be the simplest mental model. This is a framework for the frontend professional. Or a team building a complex and powerful web experience. People who want to understand:
|
|
39
|
+
|
|
40
|
+
* how the server and client co-exist (the network boundary)
|
|
41
|
+
* the distinction between build-time and request-time
|
|
42
|
+
* static routes and dynamic routes co-existing
|
|
43
|
+
* zero-javascript websites and fully static js-only SPAs
|
|
44
|
+
* how to build a composable architecture that still feels like it sits on web standards
|
|
45
|
+
|
|
46
|
+
Now this all sounds like a lot. I'm not here to say it isn't. But if you're a frontend professional, if you work on a team of experienced engineers, an architecture that sits across these complexities and doesn't abstract them gives you the power to define your boundaries: server and client, static and dynamic, client js and not.
|
|
47
|
+
|
|
48
|
+
You have to learn the inherent complexity of these ideas, but once you do, you internalize an architecture that can scale up and down. One that gives you access to both UX and DX that is unparalleled. Timber does not dumb down our abstractions to make it easier to _understand_, because that comes at the cost of optionality. It gives you the tools to _define_ your boundaries.
|
|
49
|
+
|
|
50
|
+
Other frameworks that have pursued this have either abstracted away too much, leaned on too much magic, or ignored web standards. Timber is _not_ smarter than you. It doesn't try to be. If you write an inefficient website in Timber, it won't stop you. But it also won't project onto you _how_ to build a website. It just gives you the foundation to control it yourself.
|
|
51
|
+
|
|
52
|
+
The good news is, you don't have to understand this all immediately. You can layer on your understanding one by one. And I promise, in the long-term it will click. But you may have to touch it yourself for it to click.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
{/* Here are our basic philosophies:
|
|
56
|
+
|
|
57
|
+
- All websites sit somewhere across three environments. Embracing these three as first-party targets is a bid for developer autonomy, not confusion.
|
|
58
|
+
- Server
|
|
59
|
+
- Client
|
|
60
|
+
- Isomorphic (Universal SSR)
|
|
61
|
+
- Serverless is a flawed paradigm for rendering
|
|
62
|
+
- Though it can be useful at times, owning your _own_ server (and CPU cycles) will always be more performant and cost effective at anything beyond a basic scale
|
|
63
|
+
- An inefficiency in serverless means an unbounded/infinite bill, on a server it only means degraded performance
|
|
64
|
+
- A server means your database can live _next_ to your rendering layer, which will always outperform _holistic_ edge rendering in generic real-world scenarios
|
|
65
|
+
- Your framework should never be smarter than you
|
|
66
|
+
- Good UX is invisible, good DX is transparent
|
|
67
|
+
- It's better to be understandable than magic, which means performance is fine tunable – rather than automatic
|
|
68
|
+
- The web constantly serializes and deserializes strings, the framework should provide typing and parsing to those edges
|
|
69
|
+
- Search Params
|
|
70
|
+
- Cookies
|
|
71
|
+
- Segment URL Params
|
|
72
|
+
- and more...
|
|
73
|
+
- A good web-framework scales horizontally across your surface API (routes)
|
|
74
|
+
- Adding more routes or components shouldn't affect performance on others
|
|
75
|
+
- A good web framework has finely-tuned control over static (build-time) and dynamic (run-time) rendering
|
|
76
|
+
- A good web framework has the ability to scale up and down functionally and with developer clarity, which means deploying a website that:
|
|
77
|
+
- Can ship zero client javascript
|
|
78
|
+
- Can ship static content
|
|
79
|
+
- And can scale up to a completely dynamic website */}
|
|
80
|
+
|
|
81
|
+
More here soon...
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Quick Start'
|
|
3
|
+
description: 'Create a timber.js project and see it running in 60 seconds.'
|
|
4
|
+
slug: 'quick-start'
|
|
5
|
+
notAI: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Quick Start
|
|
9
|
+
|
|
10
|
+
<Callout variant="tip" title="We recommend pnpm">
|
|
11
|
+
You can use any package manager, but we personally recommend `pnpm`.
|
|
12
|
+
|
|
13
|
+
You can install pnpm with `npm install -g pnpm`.
|
|
14
|
+
</Callout>
|
|
15
|
+
|
|
16
|
+
## Create a Project
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm create create-timber-app my-app
|
|
21
|
+
cd my-app && pnpm i
|
|
22
|
+
pnpm dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Open [http://localhost:3000](http://localhost:3000). You have a running app.
|
|
26
|
+
|
|
27
|
+
## Project Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
my-app/
|
|
31
|
+
src/
|
|
32
|
+
app/
|
|
33
|
+
layout.tsx # Root layout — wraps every page
|
|
34
|
+
page.tsx # Home page (/)
|
|
35
|
+
globals.css
|
|
36
|
+
timber.config.ts # Framework configuration
|
|
37
|
+
vite.config.ts # Vite configuration
|
|
38
|
+
package.json
|
|
39
|
+
tsconfig.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The `app/` directory is your route tree. Files named `page.tsx` become pages. Files named `layout.tsx` coelesce and wrap the page. This composable structure scales very effectively.
|
|
43
|
+
|
|
44
|
+
## Your First Page
|
|
45
|
+
|
|
46
|
+
Every page is a server component by default — it executes exclusively on the server and produces HTML.
|
|
47
|
+
|
|
48
|
+
```tsx title="app/page.tsx"
|
|
49
|
+
export default function HomePage() {
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
<h1>Hello, timber.js</h1>
|
|
53
|
+
<p>This page was server-rendered.</p>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
<Callout variant="tip" title="Server Components">
|
|
60
|
+
I know server components can seem intimidating, but I'd implore you to play with them. Even if you don't want to run a server, you can still use "server components" with distinct advantages.
|
|
61
|
+
|
|
62
|
+
You can build static SPAs or even zero-javascript websites with both server components and timber.
|
|
63
|
+
</Callout>
|
|
64
|
+
|
|
65
|
+
## Adding a Second Route
|
|
66
|
+
|
|
67
|
+
Create `app/about/page.tsx`:
|
|
68
|
+
|
|
69
|
+
```tsx title="app/about/page.tsx"
|
|
70
|
+
export default function AboutPage() {
|
|
71
|
+
return <h1>About</h1>;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Navigate to `/about` and you'll see the About page.
|
|
76
|
+
|
|
77
|
+
## Navigation
|
|
78
|
+
|
|
79
|
+
Links are added by importing the `Link` component.
|
|
80
|
+
|
|
81
|
+
```tsx title="app/about/page.tsx"
|
|
82
|
+
import { Link } from '@timber-js/app/client';
|
|
83
|
+
|
|
84
|
+
export default function AboutPage() {
|
|
85
|
+
return (
|
|
86
|
+
<div>
|
|
87
|
+
<h1>About</h1>
|
|
88
|
+
<Link href="/">Go Home</Link>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That's it. You now have a basic timber.js app up and running.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Pages and Layouts'
|
|
3
|
+
description: 'File-system routing with page.tsx, layout.tsx, and nested directory structure.'
|
|
4
|
+
slug: 'pages-and-layouts'
|
|
5
|
+
notAI: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Pages, Layouts, and Segments
|
|
9
|
+
|
|
10
|
+
timber.js uses file-based routing, inspired directly by next.js. Folders in the `app/` directory become routes. The directory structure maps to the URL structure, which maps to your component tree.
|
|
11
|
+
|
|
12
|
+
We tweaked a few things, but next's layouting and routing design is among its strongest points. This also makes it very easy to migrate to timber.
|
|
13
|
+
|
|
14
|
+
## Pages
|
|
15
|
+
|
|
16
|
+
A `page.tsx` file makes a route:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
app/
|
|
20
|
+
page.tsx # /
|
|
21
|
+
about/
|
|
22
|
+
page.tsx # /about
|
|
23
|
+
blog/
|
|
24
|
+
page.tsx # /blog
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each page exports a default component. Pages are server components by default — they can be async and fetch data directly.
|
|
28
|
+
|
|
29
|
+
```tsx title="app/blog/page.tsx"
|
|
30
|
+
export default async function BlogPage() {
|
|
31
|
+
const data = await fetchContent();
|
|
32
|
+
|
|
33
|
+
return <h1>{data.title}</h1>;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Segments
|
|
38
|
+
|
|
39
|
+
Each directory beneath the `app` directory is called a `segment`. In the example above, `about` and `blog` are segments.
|
|
40
|
+
|
|
41
|
+
A nested, dynamic segment would look something like: `./app/blog/[slug]/page.tsx`. In this case, `blog` and `[slug]` are the segments that make up this route pattern.
|
|
42
|
+
|
|
43
|
+
## Layouts
|
|
44
|
+
|
|
45
|
+
All parental `layout.tsx` files wraps a page in its subtree. Layouts receive a `children` prop and persist across navigations — they don't re-render when you navigate between sibling pages.
|
|
46
|
+
|
|
47
|
+
```tsx title="app/layout.tsx"
|
|
48
|
+
import type { ReactNode } from 'react';
|
|
49
|
+
|
|
50
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
51
|
+
return (
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<body>
|
|
54
|
+
<nav>My Site</nav>
|
|
55
|
+
<main>{children}</main>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Layouts nest. Each directory level can have its own layout:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
app/
|
|
66
|
+
layout.tsx # Root layout — wraps everything
|
|
67
|
+
page.tsx # /
|
|
68
|
+
blog/
|
|
69
|
+
layout.tsx # Blog layout — wraps all blog pages
|
|
70
|
+
page.tsx # /blog
|
|
71
|
+
[slug]/
|
|
72
|
+
page.tsx # /blog/my-post
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When you visit `/blog/my-post`, the component tree looks like:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<RootLayout>
|
|
79
|
+
<BlogLayout>
|
|
80
|
+
<BlogPostPage />
|
|
81
|
+
</BlogLayout>
|
|
82
|
+
</RootLayout>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The root layout shell (nav, footer) stays mounted when you navigate between blog posts. Only the innermost content re-renders.
|
|
86
|
+
|
|
87
|
+
## Segment Convention Files
|
|
88
|
+
|
|
89
|
+
Each directory is called a **segment** and can contain these files:
|
|
90
|
+
|
|
91
|
+
| File | What it does |
|
|
92
|
+
| --------------- | --------------------------------------------------------- |
|
|
93
|
+
| `page.tsx` | The leaf content for this route |
|
|
94
|
+
| `layout.tsx` | Persistent UI shell — wraps children, survives navigation |
|
|
95
|
+
| `access.ts` | Authorization gate — runs before the layout renders |
|
|
96
|
+
| `middleware.ts` | Pre-render hook for headers, redirects, and cache warming |
|
|
97
|
+
| `params.ts` | Typed search params definitions (see [Typed Params](/docs/typed-params)) |
|
|
98
|
+
| `error.tsx` | Error boundary catching failures in this subtree |
|
|
99
|
+
| `404.tsx` | Denial page for 404 status code (must be a client component) |
|
|
100
|
+
|
|
101
|
+
None are required. A segment with just `layout.tsx` provides shared UI. One with just `page.tsx` is a leaf route.
|
|
102
|
+
|
|
103
|
+
<Callout variant="tip" title="No loading.tsx">
|
|
104
|
+
You'll notice there's no `loading.tsx` file. This is intentional and by design.
|
|
105
|
+
|
|
106
|
+
We'll learn more about _why_ later on.
|
|
107
|
+
</Callout>
|
|
108
|
+
|
|
109
|
+
## Route Groups
|
|
110
|
+
|
|
111
|
+
Directories wrapped in parentheses create a segment boundary without affecting the URL:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
app/
|
|
115
|
+
(marketing)/
|
|
116
|
+
page.tsx # /
|
|
117
|
+
about/
|
|
118
|
+
page.tsx # /about
|
|
119
|
+
(dashboard)/
|
|
120
|
+
layout.tsx # Dashboard layout (shared sidebar, etc.)
|
|
121
|
+
settings/
|
|
122
|
+
page.tsx # /settings
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use groups to organize by concern — auth boundaries, public vs private areas, or custom layout structures — without polluting URLs.
|
|
126
|
+
|
|
127
|
+
## Private Folders
|
|
128
|
+
|
|
129
|
+
Prefix a directory with `_` to exclude it from routing:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
app/
|
|
133
|
+
_components/ # Not a route — co-locate shared code here
|
|
134
|
+
Button.tsx
|
|
135
|
+
page.tsx # /
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For dynamic segments, advanced routing patterns (parallel routes, intercepting routes, catch-all segments), and API routes, see [Advanced Routing](/docs/advanced-routing).
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Fetching Data'
|
|
3
|
+
description: 'Async server components, deny(404), and dynamic segments.'
|
|
4
|
+
slug: 'fetching-data'
|
|
5
|
+
notAI: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Fetching Data
|
|
9
|
+
|
|
10
|
+
```ts title="app/schema.ts" id="schema" hidden
|
|
11
|
+
import { defineSchema } from '@timber-js/app/params';
|
|
12
|
+
import { codec } from '@timber-js/app/codec';
|
|
13
|
+
|
|
14
|
+
export default defineSchema({
|
|
15
|
+
segmentParams: {
|
|
16
|
+
'[id]': codec.string,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
declare function fetchProducts(opts: { take: number }): Promise<{ id: string; name: string }[]>;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Timber and RSCs do not prescribe _where you must_ fetch data. They only give you _more optionality_ as to where.
|
|
24
|
+
|
|
25
|
+
**You** (the developer) can decide where and when you want to fetch data (whether it be the server, or the client, or at build-time). Let's walk through each.
|
|
26
|
+
|
|
27
|
+
Server components are async — in general, this is the first place to reach for data fetching (or querying).
|
|
28
|
+
|
|
29
|
+
```tsx title="app/page.tsx"
|
|
30
|
+
export default async function HomePage() {
|
|
31
|
+
const products = await fetchProducts({ take: 10 });
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ul>
|
|
35
|
+
{products.map((p) => (
|
|
36
|
+
<li key={p.id}>{p.name}</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Though it might feel confusing at first, you'll find that it's *often* the best place to fetch your data. It fits cleanly into the request/response model, prevents client-side waterfalls, and is generally the _fastest_ round-trip. Though there _will_ be scenarios where data fetching in other places (i.e. the client) make more sense depending on each context. Don't be afraid to mix and match data fetching, this is the whole point of timber: optionality.
|
|
44
|
+
|
|
45
|
+
## Dynamic Segments
|
|
46
|
+
|
|
47
|
+
Wrap a directory name in brackets to create a dynamic segment that captures a URL parameter:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
app/
|
|
51
|
+
products/
|
|
52
|
+
[id]/
|
|
53
|
+
page.tsx # /products/42, /products/abc
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Access the param with `getSegmentParams()`. Import `SEGMENT_PATH` from `$segment` for exact types:
|
|
57
|
+
|
|
58
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
59
|
+
import { SEGMENT_PATH } from './$segment';
|
|
60
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
61
|
+
|
|
62
|
+
declare function getProduct(id: string): Promise<{ name: string; description: string } | null>;
|
|
63
|
+
|
|
64
|
+
export default async function ProductPage() {
|
|
65
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
66
|
+
const product = await getProduct(id);
|
|
67
|
+
|
|
68
|
+
if (!product) return deny(404);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div>
|
|
72
|
+
<h1>{product.name}</h1>
|
|
73
|
+
<p>{product.description}</p>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`deny(404)` sends a real HTTP 404 — not a 200 with an error message. Search engines, CDNs, and `curl -f` all see the correct status code.
|
|
80
|
+
|
|
81
|
+
For typed params (numbers, UUIDs, enums), define codecs in `app/schema.ts`. See [Typed Params](/docs/typed-params).
|
|
82
|
+
|
|
83
|
+
## Server Components Span Three Environments
|
|
84
|
+
|
|
85
|
+
React Server Components split your code into three worlds:
|
|
86
|
+
|
|
87
|
+
**Server components** (default) — run on the server only. Their code never reaches the client. You can `await` database queries, access secrets, import heavy libraries. Zero client-side JavaScript cost.
|
|
88
|
+
|
|
89
|
+
**Client components** (`'use client'`) — run in the browser (and on the server for the initial HTML render). Use them for `useState`, `useEffect`, event handlers, and browser APIs.
|
|
90
|
+
|
|
91
|
+
**Server actions** (`'use server'`) — functions that run on the server but are callable from the client. Used for mutations — form submissions, data updates.
|
|
92
|
+
|
|
93
|
+
These compose. A server component can render a client component as a child. Server actions wire the client back to the server for mutations:
|
|
94
|
+
|
|
95
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
96
|
+
import { SEGMENT_PATH } from './$segment';
|
|
97
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
98
|
+
import { AddToCartButton } from './add-to-cart';
|
|
99
|
+
|
|
100
|
+
declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
|
|
101
|
+
|
|
102
|
+
export default async function ProductPage() {
|
|
103
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
104
|
+
const product = await getProduct(id);
|
|
105
|
+
if (!product) return deny(404);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<h1>{product.name}</h1>
|
|
110
|
+
<AddToCartButton productId={product.id} />
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```tsx title="app/products/[id]/add-to-cart.tsx"
|
|
117
|
+
'use client';
|
|
118
|
+
|
|
119
|
+
import { useState } from 'react';
|
|
120
|
+
|
|
121
|
+
export function AddToCartButton({ productId }: { productId: string }) {
|
|
122
|
+
const [added, setAdded] = useState(false);
|
|
123
|
+
return (
|
|
124
|
+
<button onClick={() => setAdded(true)}>
|
|
125
|
+
{added ? 'Added ✓' : 'Add to Cart'}
|
|
126
|
+
</button>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The product page renders on the server (zero JS cost). The button hydrates on the client for interactivity.
|
|
132
|
+
|
|
133
|
+
## No Implicit Caching
|
|
134
|
+
|
|
135
|
+
`fetch()` is never patched or cached. A bare `fetch()` call hits the network every time. If you want cross-request caching, use `timber.cache()` — see [Caching](/docs/caching). If you want single-request caching, use `React.cache`.
|
|
136
|
+
|
|
137
|
+
Timber will never try to optimize your code through _magic_. If you want to build an efficient website, you'll have to understand when and where to place your caching or optimizations.
|
|
138
|
+
|
|
139
|
+
## Parallel Data Fetching
|
|
140
|
+
|
|
141
|
+
Fetch multiple things concurrently with `Promise.all`:
|
|
142
|
+
|
|
143
|
+
```tsx title="app/dashboard/page.tsx"
|
|
144
|
+
declare function getUser(): Promise<{ name: string }>;
|
|
145
|
+
declare function getStats(): Promise<{ revenue: number }>;
|
|
146
|
+
declare function getNotifications(): Promise<{ id: string; text: string }[]>;
|
|
147
|
+
|
|
148
|
+
export default async function DashboardPage() {
|
|
149
|
+
const [user, stats, notifications] = await Promise.all([
|
|
150
|
+
getUser(),
|
|
151
|
+
getStats(),
|
|
152
|
+
getNotifications(),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div>
|
|
157
|
+
<UserHeader user={user} />
|
|
158
|
+
<StatsGrid stats={stats} />
|
|
159
|
+
<NotificationList items={notifications} />
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## `React.cache` for Request Deduplication
|
|
166
|
+
|
|
167
|
+
If multiple components in the same request need the same data, use `React.cache`:
|
|
168
|
+
|
|
169
|
+
```tsx title="lib/data.ts"
|
|
170
|
+
import { cache } from 'react';
|
|
171
|
+
|
|
172
|
+
declare function fetchUser(id: string): Promise<{ id: string; name: string; email: string }>;
|
|
173
|
+
|
|
174
|
+
export const getUser = cache(async (id: string) => {
|
|
175
|
+
return fetchUser(id);
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This deduplicates within a single request. For cross-request caching with TTL, use `timber.cache()` — see [Caching](/docs/caching).
|
|
180
|
+
|
|
181
|
+
## Streaming Secondary Content
|
|
182
|
+
|
|
183
|
+
Primary content fetches outside `<Suspense>` — it can affect the status code. Secondary content goes inside `<Suspense>` — it streams after the shell flushes.
|
|
184
|
+
|
|
185
|
+
```tsx title="app/products/[id]/page.tsx" use="schema"
|
|
186
|
+
import { Suspense } from 'react';
|
|
187
|
+
import { SEGMENT_PATH } from './$segment';
|
|
188
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
189
|
+
|
|
190
|
+
declare function getProduct(id: string): Promise<{ id: string; name: string } | null>;
|
|
191
|
+
declare function ProductHeader(props: { product: { name: string } }): React.ReactElement;
|
|
192
|
+
declare function ReviewsSkeleton(): React.ReactElement;
|
|
193
|
+
declare function ProductReviews(props: { productId: string }): React.ReactElement;
|
|
194
|
+
|
|
195
|
+
export default async function ProductPage() {
|
|
196
|
+
const { id } = getSegmentParams(SEGMENT_PATH);
|
|
197
|
+
const product = await getProduct(id);
|
|
198
|
+
if (!product) deny(404);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div>
|
|
202
|
+
<ProductHeader product={product} />
|
|
203
|
+
<Suspense fallback={<ReviewsSkeleton />}>
|
|
204
|
+
<ProductReviews productId={product.id} />
|
|
205
|
+
</Suspense>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`getProduct` runs before the flush — if it fails, you get a real 404. Reviews stream in after. More on this in [The Flush Point](/docs/the-flush-point).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Access Control: access.ts'
|
|
3
|
+
description: 'Per-segment access control with access.ts, deny(), and graceful slot degradation.'
|
|
4
|
+
slug: 'access-control'
|
|
5
|
+
notAI: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Access Control
|
|
9
|
+
|
|
10
|
+
```ts title="app/schema.ts" id="schema" hidden
|
|
11
|
+
import { defineSchema } from '@timber-js/app/params';
|
|
12
|
+
import { codec } from '@timber-js/app/codec';
|
|
13
|
+
|
|
14
|
+
import { getCookieJar, redirect } from '@timber-js/app/server';
|
|
15
|
+
|
|
16
|
+
declare function getSessionFromCookie(jar: ReturnType<typeof getCookieJar>): { userId: string } | null;
|
|
17
|
+
declare function getUser(userId: string): Promise<{ id: string; name: string }>;
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export default defineSchema({
|
|
21
|
+
segmentParams: {
|
|
22
|
+
'[projectId]': codec.string,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Most websites use access control of some kind. Instead of DRY-ing your access control or duplicating checks across your codebase, timber provides a _composable_ form of access control: `access.ts`.
|
|
28
|
+
|
|
29
|
+
If you place an `access.ts` file anywhere in your route tree, every sub-route will execute every `access.ts` in its parental tree.
|
|
30
|
+
|
|
31
|
+
You can `redirect`, `deny`, or otherwise gate access to an entire route group.
|
|
32
|
+
|
|
33
|
+
## `access.ts`
|
|
34
|
+
|
|
35
|
+
```ts title="app/(authenticated)/access.ts"
|
|
36
|
+
import { getCookieJar, redirect } from '@timber-js/app/server';
|
|
37
|
+
|
|
38
|
+
export default async function access() {
|
|
39
|
+
const session = getSessionFromCookie(getCookieJar());
|
|
40
|
+
|
|
41
|
+
if (!session) return redirect('/login');
|
|
42
|
+
|
|
43
|
+
const user = await getUser(); // getUser is wrapped in React.cache
|
|
44
|
+
|
|
45
|
+
if (!user.isVerified) return deny(401);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`access.ts` is a gate — it passes or fails. Return values are discarded. If it doesn't throw or redirect, rendering proceeds.
|
|
50
|
+
|
|
51
|
+
`access` runs before a route's layout or page in sequence, so if it short-circuits, those components are never executed.
|
|
52
|
+
|
|
53
|
+
## Sharing Data Between Access and Layout
|
|
54
|
+
|
|
55
|
+
Access checks and layouts share the same `React.cache` scope. Call the same cached function in both — the layout gets a free cache hit:
|
|
56
|
+
|
|
57
|
+
```tsx title="app/(authenticated)/layout.tsx"
|
|
58
|
+
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
|
59
|
+
const user = await getUser(); // Cache HIT — access.ts already resolved this
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div>
|
|
63
|
+
<nav>{user.name}</nav>
|
|
64
|
+
<main>{children}</main>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Nested Access Checks
|
|
71
|
+
|
|
72
|
+
Deeper segments add their own `access.ts` for additional authorization:
|
|
73
|
+
|
|
74
|
+
```ts title="app/(authenticated)/projects/[projectId]/access.ts" use="schema"
|
|
75
|
+
import { SEGMENT_PATH } from './$segment';
|
|
76
|
+
import { deny, getSegmentParams } from '@timber-js/app/server';
|
|
77
|
+
|
|
78
|
+
declare function requireUser(): Promise<{ id: string; orgId: string }>;
|
|
79
|
+
declare function getProject(id: string): Promise<{ orgId: string } | null>;
|
|
80
|
+
|
|
81
|
+
export default async function access() {
|
|
82
|
+
const user = await requireUser(); // Cache HIT from parent
|
|
83
|
+
const { projectId } = getSegmentParams(SEGMENT_PATH);
|
|
84
|
+
const project = await getProject(projectId);
|
|
85
|
+
if (!project) deny(404);
|
|
86
|
+
if (project.orgId !== user.orgId) deny(); // Default 403
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Access runs root-to-leaf. If the root gate fails, deeper gates never execute.
|
|
91
|
+
|
|
92
|
+
## `deny()`
|
|
93
|
+
|
|
94
|
+
`deny()` produces the correct HTTP status code and renders the matching status file:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
deny(); // 403 Forbidden (default)
|
|
98
|
+
deny(404); // 404 Not Found
|
|
99
|
+
deny(401); // 401 Unauthorized
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Slot Degradation
|
|
103
|
+
|
|
104
|
+
When `access.ts` denies a parallel route (slot), only that slot degrades — the page continues rendering:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
app/
|
|
108
|
+
dashboard/
|
|
109
|
+
layout.tsx # Receives { children, admin }
|
|
110
|
+
page.tsx
|
|
111
|
+
@admin/
|
|
112
|
+
access.ts # Checks admin role
|
|
113
|
+
page.tsx # Admin panel
|
|
114
|
+
denied.tsx # Shown when access fails
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If the user isn't an admin, `@admin` renders `denied.tsx` while the rest of the dashboard loads normally.
|
|
118
|
+
|
|
119
|
+
When access denies, that segment's `layout.tsx` never runs — `denied.tsx` renders in its place, wrapped only by layouts above the denying `access.ts`. This holds at every level of a slot's tree: a nested `@admin/reports/access.ts` denial prevents `@admin/reports/layout.tsx` from executing (and from fetching data it would serialize into the response).
|
|
120
|
+
|
|
121
|
+
## Middleware vs Access
|
|
122
|
+
|
|
123
|
+
| | `middleware.ts` | `access.ts` |
|
|
124
|
+
| ------------- | -------------------------------------- | --------------------------------- |
|
|
125
|
+
| Runs | Before rendering | Inside React tree |
|
|
126
|
+
| Purpose | Lightweight checks, redirects, headers | Auth, data gating |
|
|
127
|
+
| `React.cache` | Not available | Active — shares scope with layout |
|
|
128
|
+
| Slots | N/A | Graceful degradation |
|
|
129
|
+
|
|
130
|
+
Use `middleware.ts` for headers, redirects, and cache warming. Use `access.ts` for auth — it has access to `React.cache` and supports slot degradation.
|