@rsc-kit/mcp 0.16.3 → 0.18.0
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/dist/answers.d.ts +14 -1
- package/dist/answers.js +76 -34
- package/dist/answers.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +434 -14
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +12 -0
- package/dist/report.js +17 -15
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +106 -6
- package/guides/authorization.md +39 -0
- package/guides/backend-answered-pages.md +163 -0
- package/guides/coming-from-next.md +12 -4
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/emails.md +90 -0
- package/guides/errors.md +16 -2
- package/guides/go.md +194 -0
- package/guides/images.md +10 -4
- package/guides/index.json +50 -0
- package/guides/installation.md +31 -13
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- package/guides/mcp.md +1 -1
- package/guides/metadata.md +1 -1
- package/guides/offline.md +8 -0
- package/guides/queries.md +138 -8
- package/guides/quick-start.md +31 -0
- package/guides/redirects.md +14 -0
- package/guides/response-headers.md +22 -0
- package/guides/routing.md +5 -1
- package/guides/seo-files.md +140 -0
- package/guides/typed-routes.md +26 -0
- package/guides/view-transitions.md +53 -7
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Backend-Answered Pages
|
|
2
|
+
|
|
3
|
+
> BAP — the model for an rsc-kit app with a backend behind it, and how to build for it.
|
|
4
|
+
|
|
5
|
+
An rsc-kit app with a backend behind it — Laravel, Go, anything that answers
|
|
6
|
+
the contract — is a **BAP: Backend-Answered Pages**. A page is rendered *in
|
|
7
|
+
front of* the backend rather than *by* it. The renderer asks; the backend
|
|
8
|
+
answers.
|
|
9
|
+
|
|
10
|
+
This page is the whole idea, once. The [Laravel](/hosts/laravel) and
|
|
11
|
+
[Go](/hosts/go) pages are how each backend answers, and
|
|
12
|
+
[Your own backend](/hosts/your-own-backend) is the contract a third one
|
|
13
|
+
implements.
|
|
14
|
+
|
|
15
|
+
## Beside the shapes you know
|
|
16
|
+
|
|
17
|
+
| | who renders the page | who the backend talks to | how a page gets data |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| **MPA** — Blade, Rails views, Inertia | the backend | the browser | in the controller, before the view |
|
|
20
|
+
| **SPA** — React + an API | the browser | the browser, over an API | `fetch('/api/orders')` after load |
|
|
21
|
+
| **BAP** — rsc-kit | a renderer, on the server | the renderer, over loopback | `await rpc('Orders.recent')` inside the component |
|
|
22
|
+
|
|
23
|
+
If you have built a SPA with an API behind it, you know most of the shape:
|
|
24
|
+
the React app is what the visitor sees, and the backend is the thing it asks
|
|
25
|
+
for data. The one change is that **the React app is rendered on a server, and
|
|
26
|
+
that server is the one asking the backend** — not the browser. The visitor
|
|
27
|
+
gets a finished, streamed page; the backend never builds one.
|
|
28
|
+
|
|
29
|
+
In plain terms: a restaurant. In an MPA you order from the kitchen and the
|
|
30
|
+
kitchen sends out the whole plate. In a SPA the kitchen hands you an empty
|
|
31
|
+
plate and a recipe, and your browser cooks at the table, running back for
|
|
32
|
+
every ingredient. In a BAP there is a chef between you and the kitchen — the
|
|
33
|
+
renderer. You order a page; the chef plates it right there, asking the kitchen
|
|
34
|
+
through a hatch for what only the kitchen has: your orders, whether you are
|
|
35
|
+
signed in, whether you may be in this room. The kitchen only ever answers. And
|
|
36
|
+
the dishes that never change, the chef plated in advance.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
browser → renderer ──render──▶ React
|
|
42
|
+
│
|
|
43
|
+
├─ before rendering: POST /__rsc/host-call { "function": "__rsc.middleware", "args": [["auth"]] }
|
|
44
|
+
└─ during rendering: POST /__rsc/host-call { "calls": [{ "function": "Orders.recent", "args": [5] }, …] }
|
|
45
|
+
│
|
|
46
|
+
▼
|
|
47
|
+
your backend
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **The renderer is the front door.** It is what `vite dev` serves and what
|
|
51
|
+
Nitro builds into `.output/server`: it routes, renders, serves the pages it
|
|
52
|
+
froze at build time and the assets, and streams the rest.
|
|
53
|
+
- **One endpoint, one direction.** The renderer calls the backend, never the
|
|
54
|
+
reverse, with a shared secret the backend checks on every call. The
|
|
55
|
+
visitor's cookie travels with the call, so the backend's session and auth
|
|
56
|
+
are the visitor's. The backend needs no credential to reach the renderer;
|
|
57
|
+
the renderer's pages are the public site.
|
|
58
|
+
- **Everything else stays the backend's.** Any url the React tree does not
|
|
59
|
+
own — `/login`, a webhook, an OAuth callback, a file under `/storage`, an
|
|
60
|
+
admin panel — is forwarded to the backend as it is. Per url the rule is: if
|
|
61
|
+
the React tree has it, React renders it; otherwise the backend does. Nothing
|
|
62
|
+
is half-and-half on one url.
|
|
63
|
+
- **Calls are batched.** Sibling components each awaiting `rpc()` are one
|
|
64
|
+
request to the backend, answered in order; a guarded page is typically two
|
|
65
|
+
backend requests — the guard, then the batch of its reads.
|
|
66
|
+
|
|
67
|
+
## What the backend is, then
|
|
68
|
+
|
|
69
|
+
Neither an API nor an MPA. It is the **backend** in the literal sense — the
|
|
70
|
+
part of the application that is not a page: models and the database, sessions
|
|
71
|
+
and auth, policies, validation, queues, mail, events. It does not build pages,
|
|
72
|
+
so it is not an MPA; it does not expose a JSON API to the browser, so it is
|
|
73
|
+
not an API server. It answers one private endpoint that only the renderer can
|
|
74
|
+
call, with functions you write as ordinary classes.
|
|
75
|
+
|
|
76
|
+
For a Laravel app that means: Laravel stops being the app that serves pages
|
|
77
|
+
and becomes the app that answers them. Eloquent, policies, form requests,
|
|
78
|
+
queues, notifications — all of it stays, and none of it is behind a controller
|
|
79
|
+
any more.
|
|
80
|
+
|
|
81
|
+
## Building for it
|
|
82
|
+
|
|
83
|
+
**Pages read by name.** A server component calls the backend the way it would
|
|
84
|
+
call a function, because from its side it is one:
|
|
85
|
+
|
|
86
|
+
```tsx title="src/app/orders/page.tsx"
|
|
87
|
+
export default async function Orders() {
|
|
88
|
+
const orders = await rpc<Order[]>('Orders.recent', 5)
|
|
89
|
+
|
|
90
|
+
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
No API to design, no routes to declare, no JSON layer between a component and
|
|
95
|
+
a method. `rpc()` exists in server components during a render and nowhere
|
|
96
|
+
else — the browser never calls the backend directly.
|
|
97
|
+
|
|
98
|
+
**Guards are the backend's, named in `middleware.ts`.** The backend decides
|
|
99
|
+
whether a route may render, in its own vocabulary, and the renderer asks
|
|
100
|
+
before anything at or below that directory renders — including a page frozen
|
|
101
|
+
at build time:
|
|
102
|
+
|
|
103
|
+
```ts title="src/app/admin/middleware.ts"
|
|
104
|
+
export const middleware = ['auth', 'can:manage-orders']
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A refusal is its own kind on the wire — unauthenticated, unauthorized, a
|
|
108
|
+
redirect, a throttle's 429 — and reaches the page as that, never as a 500.
|
|
109
|
+
|
|
110
|
+
**Mutations are server actions the backend implements.** A function the
|
|
111
|
+
backend registers as an action gets a `"use server"` stub written by the
|
|
112
|
+
build, and a client component imports and calls it. The action says what it
|
|
113
|
+
made stale (`Rsc::revalidate('orders')`, `rsckit.Revalidate(ctx, "orders")`)
|
|
114
|
+
and the answer carries the re-rendered region back.
|
|
115
|
+
|
|
116
|
+
**Browser-driven reads are queries that call `rpc()`.** A `query()` is a
|
|
117
|
+
JavaScript server function, so its body can be one call into the backend, and
|
|
118
|
+
the browser reads it over GET with [`fetchQuery`, `usePolling`, TanStack or
|
|
119
|
+
SWR](/guides/queries) on top:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export const recentOrders = client.query(async () => rpc<Order[]>('Orders.recent', 5))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Put things where they belong.** Data, identity, rules and jobs are the
|
|
126
|
+
backend's; layout, interaction and everything a visitor sees is React's. A
|
|
127
|
+
page that needs nothing from the backend is frozen at build time and never
|
|
128
|
+
touches it; one that reads per visitor is a shell with holes the backend
|
|
129
|
+
fills. The build's table says which is which.
|
|
130
|
+
|
|
131
|
+
## Running it
|
|
132
|
+
|
|
133
|
+
Two processes, and the decision that matters is which faces the internet.
|
|
134
|
+
|
|
135
|
+
**The renderer in front** is the arrangement to reach for: it serves assets
|
|
136
|
+
and frozen pages straight off disk, renders the rest, forwards what it does
|
|
137
|
+
not own, and holds a backend worker for the length of a *host call*, never a
|
|
138
|
+
render. Restrict the host-call endpoint at the web server so only the renderer
|
|
139
|
+
reaches it.
|
|
140
|
+
|
|
141
|
+
**The backend in front** proxies pages to the renderer — the dev-mode shape,
|
|
142
|
+
and what a Laravel app does with `RSC_RENDERER_URL`. It holds a worker for the
|
|
143
|
+
whole render while the render calls back for data, so it needs several
|
|
144
|
+
workers and caps concurrency at one fewer than it has.
|
|
145
|
+
|
|
146
|
+
**What a call costs** is the backend handling a request — under PHP-FPM a
|
|
147
|
+
framework boot, under Octane or a Go process about a millisecond — times the
|
|
148
|
+
number of *sequential* rounds a page needs, which batching keeps to one or
|
|
149
|
+
two.
|
|
150
|
+
|
|
151
|
+
## When to choose it
|
|
152
|
+
|
|
153
|
+
A BAP earns its second process when the pages want what a server-rendered
|
|
154
|
+
React app gives — streaming, server components with no client bundle, static
|
|
155
|
+
and partially-prerendered pages, typed routes and actions — *and* the data,
|
|
156
|
+
identity and rules already live in a backend you are keeping. An app whose
|
|
157
|
+
backend is only a database is better as a plain rsc-kit app, where the
|
|
158
|
+
"backend" is an import. An app whose pages are simple forms over a Laravel
|
|
159
|
+
model may be happier as an MPA. Everything in between is what this is for.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
Next: [Laravel](/hosts/laravel) · [Go](/hosts/go) · [Your own backend](/hosts/your-own-backend)
|
|
@@ -44,7 +44,13 @@ it, the server evaluates the library's internals for nothing on every render.
|
|
|
44
44
|
| `next/script` | [a `<script>` tag](/guides/third-party-scripts): React 19 hoists and dedupes `async` scripts itself |
|
|
45
45
|
| `NEXT_PUBLIC_*` | `VITE_*`, read through `import.meta.env`; everything else stays `process.env` on the server |
|
|
46
46
|
| `next.config.js` | `vite.config.ts` — Tailwind, aliases and plugins are Vite's |
|
|
47
|
+
| `instrumentation.ts` with `register()` | the same file, in `src/` — imported before any page and awaited before the first request; [startup](/guides/instrumentation) |
|
|
47
48
|
| `next-safe-action` | `createActionClient()` — same shape, [below](#actions) |
|
|
49
|
+
| `experimental.optimizePackageImports` | on by default for every barrel package the server imports, with no list — read from the barrel itself; `rscKit({ barrelImports: false })` turns it off |
|
|
50
|
+
| a `middleware.ts` rewrite for subdomains | nothing — a host is a route segment, so `acme.example.com/` reaches `app/[domain]/page.tsx` and `admin.example.com/` reaches `app/admin/page.tsx` — [domains](/guides/domains) |
|
|
51
|
+
| `app/robots.ts`, `app/sitemap.ts` | the same files, the same shapes — [robots, sitemap and llms.txt](/guides/seo-files); `app/llms.ts` beside them |
|
|
52
|
+
| `@react-email/render` in a server action | the same call, in a module that starts with `"use ssr"` — [emails](/guides/emails). Next fails the same way where it renders server components; the directive is how this one moves it |
|
|
53
|
+
| `cache` from `react` | `cache` from `@rsc-kit/core/cache` — React's memoises only inside a component render; this one spans the request, so a guard, the layout and the action share one call. The build names server files still importing React's |
|
|
48
54
|
|
|
49
55
|
## Different on purpose
|
|
50
56
|
|
|
@@ -59,7 +65,7 @@ which, and why:
|
|
|
59
65
|
```
|
|
60
66
|
○ /about no js
|
|
61
67
|
◐ /orders 85 kB
|
|
62
|
-
|
|
68
|
+
cookies() in RootLayout streams per request; the rest is stored
|
|
63
69
|
```
|
|
64
70
|
|
|
65
71
|
`await connection()` is the one explicit mark, for a page that must render per
|
|
@@ -145,10 +151,12 @@ in Next; see [Testing](/guides/testing).
|
|
|
145
151
|
## The porting order that worked
|
|
146
152
|
|
|
147
153
|
1. `bun create rsc-kit@latest` and copy `src/app` over the scaffold's.
|
|
148
|
-
2. Fix imports from the table. `bun run typecheck` finds the rest
|
|
154
|
+
2. Fix imports from the table. `bun run typecheck` finds the rest — and the
|
|
155
|
+
build runs the same check first, as `next build` does, so a link to a
|
|
156
|
+
route that does not exist cannot ship.
|
|
149
157
|
3. `bun run build` and **read the output**: every route that is not `○` says
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
what streams and from which component. Most surprises are a `cookies()`
|
|
159
|
+
in a layout reaching every page — the build says so under the summary.
|
|
152
160
|
4. Actions not built from a client are listed. Decide for each.
|
|
153
161
|
5. `bun run check`. Then a browser, for the parts that are a browser's.
|
|
154
162
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Deploying
|
|
2
|
+
|
|
3
|
+
> What to ship, and the two settings that fail quietly.
|
|
4
|
+
|
|
5
|
+
`npm run build` writes one directory, and it is the deployment:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
.output/server the server Nitro built, and the engine it calls
|
|
9
|
+
.output/public hashed assets and the pages frozen at build time
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm run build
|
|
14
|
+
npm run start # node or bun .output/server/index.mjs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`npx vite preview` runs the same build through Vite's preview server, which is
|
|
18
|
+
what Nitro suggests at the end of a build. Both serve the real thing; `start`
|
|
19
|
+
is what a deployment runs.
|
|
20
|
+
|
|
21
|
+
## Anywhere that runs the runtime
|
|
22
|
+
|
|
23
|
+
`.output/` is self-contained. Copy it and start it — a container, a VPS, a
|
|
24
|
+
process manager, a platform that runs a Node or Bun process. There is nothing to
|
|
25
|
+
register and no platform API to satisfy, and **no `node_modules`**: the
|
|
26
|
+
dependencies are in the bundle.
|
|
27
|
+
|
|
28
|
+
```dockerfile
|
|
29
|
+
FROM oven/bun:1 AS build
|
|
30
|
+
WORKDIR /app
|
|
31
|
+
COPY package.json bun.lock ./
|
|
32
|
+
RUN bun install --frozen-lockfile
|
|
33
|
+
COPY . .
|
|
34
|
+
RUN bun run build
|
|
35
|
+
|
|
36
|
+
FROM oven/bun:1
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
COPY --from=build /app/.output ./.output
|
|
39
|
+
CMD ["bun", ".output/server/index.mjs"]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The second stage carries `.output` and nothing else. On the docs application
|
|
43
|
+
that is 1.0 MB against 104 MB of `node_modules`.
|
|
44
|
+
|
|
45
|
+
## Or a platform, without a Dockerfile
|
|
46
|
+
|
|
47
|
+
Change the preset and Nitro produces what that platform expects — a Worker and
|
|
48
|
+
its `wrangler.json`, a Vercel function, a Netlify handler. See
|
|
49
|
+
[Where it runs](/hosts/where-it-runs).
|
|
50
|
+
|
|
51
|
+
## Two things that fail quietly
|
|
52
|
+
|
|
53
|
+
**Do not set `NODE_ENV` when starting the server.** The build bakes its mode
|
|
54
|
+
into the bundle, so a server started with nothing set is production because it
|
|
55
|
+
was *built* that way. Setting it at start time is a second source of truth and
|
|
56
|
+
the one that can disagree — and when it disagrees the failure is silent: every
|
|
57
|
+
page renders, and none of them hydrate.
|
|
58
|
+
|
|
59
|
+
The check is React's debug rows in the payload:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
curl -s https://your-app.example.com/ | grep -c ':D{'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`0` on a correct production build. Anything else means a development bundle
|
|
66
|
+
reached the client.
|
|
67
|
+
|
|
68
|
+
**Serve `.output/public` at the root.** Nitro does this itself, so this only
|
|
69
|
+
matters behind a CDN: point it at that directory and let the hashed filenames do
|
|
70
|
+
the caching — they are content-addressed, so they can be cached forever.
|
|
71
|
+
|
|
72
|
+
## Server actions across a deploy
|
|
73
|
+
|
|
74
|
+
A server action can close over server-side values, and React encrypts those
|
|
75
|
+
before sending them to the browser so the page cannot read them. The process
|
|
76
|
+
that decrypts them on the way back has to hold the same key.
|
|
77
|
+
|
|
78
|
+
By default that key is generated at build time and baked in. Every instance
|
|
79
|
+
running the same build agrees, so the only exposure is the deploy itself: a
|
|
80
|
+
browser sitting on a page from the old build calls an action on the new one,
|
|
81
|
+
and the key has changed underneath it. The call fails.
|
|
82
|
+
|
|
83
|
+
For most apps that window is seconds and nobody notices. If yours is long
|
|
84
|
+
enough to care about — a slow rollout, long-lived pages, an app people leave
|
|
85
|
+
open — pin the key:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# once, kept wherever you keep secrets
|
|
89
|
+
openssl rand -base64 32
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
RSC_ACTION_ENCRYPTION_KEY=<that value>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Set it at **build time** and the build stops baking its own; the value is read
|
|
97
|
+
from the environment when the server runs, so the same artifact deploys
|
|
98
|
+
anywhere.
|
|
99
|
+
|
|
100
|
+
:::danger[Set it everywhere or nowhere]
|
|
101
|
+
Half-configured is worse than unconfigured. If one instance reads the variable
|
|
102
|
+
and another falls back to a baked key, they disagree, and the symptom is an
|
|
103
|
+
action that fails for some visitors and not others with nothing in the logs
|
|
104
|
+
pointing at a key.
|
|
105
|
+
|
|
106
|
+
Unset, everything works — the build-time key is used, which is the default
|
|
107
|
+
precisely because it cannot be got half right.
|
|
108
|
+
:::
|
|
109
|
+
|
|
110
|
+
## Frozen pages and the routes that own them
|
|
111
|
+
|
|
112
|
+
`.output/public` holds whole frozen pages and PPR shells alongside the assets.
|
|
113
|
+
They are read through the `prerendered` reader, which is a function rather than a
|
|
114
|
+
directory precisely so a runtime with no filesystem — a Worker — can supply them
|
|
115
|
+
from a binding instead.
|
|
116
|
+
|
|
117
|
+
A route that declares middleware is never cached publicly: it is sent as
|
|
118
|
+
`private, no-store`, because middleware runs per visitor. If something in front
|
|
119
|
+
of your app also owns the response — an auth proxy re-issuing a session on
|
|
120
|
+
pass-through — give the paths it covers a `middleware.ts` so this host knows
|
|
121
|
+
they are covered. See [serving shells from a CDN](/guides/edge-caching).
|
|
122
|
+
|
|
123
|
+
## Rebuild on deploy
|
|
124
|
+
|
|
125
|
+
Cached responses carry a build version, so a deploy invalidates them.
|
|
126
|
+
|
|
127
|
+
Ship `.output/` from the same commit as the code that serves it. A server
|
|
128
|
+
running one build against another's frozen pages is the one combination nothing
|
|
129
|
+
checks for you.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Domains and subdomains
|
|
2
|
+
|
|
3
|
+
> A host as a route segment — admin.example.com reaches app/admin, a tenant's host binds [domain] — with nothing to rewrite.
|
|
4
|
+
|
|
5
|
+
Next routes a subdomain with a `middleware.ts` that rewrites
|
|
6
|
+
`acme.example.com/settings` to `/acme/settings` before matching, and the
|
|
7
|
+
route tree never learns a host was involved. Here the same rule is the
|
|
8
|
+
router's own, so the build can see it: typed routes, one stored page per
|
|
9
|
+
tenant, and no middleware to write.
|
|
10
|
+
|
|
11
|
+
## The rule
|
|
12
|
+
|
|
13
|
+
A request from a host that is not the site's own is matched **with the host
|
|
14
|
+
in front of the path**:
|
|
15
|
+
|
|
16
|
+
| request | matched as | file |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `example.com/admin` | `/admin` | `app/admin/page.tsx` |
|
|
19
|
+
| `admin.example.com/` | `/admin` | `app/admin/page.tsx` — the same file |
|
|
20
|
+
| `acme.example.com/settings` | `/acme/settings` | `app/[domain]/settings/page.tsx`, `domain: "acme"` |
|
|
21
|
+
| `acme.com/settings` | `/acme.com/settings` | the same file, `domain: "acme.com"` |
|
|
22
|
+
|
|
23
|
+
A subdomain of the site contributes its label; any other host contributes the
|
|
24
|
+
whole host. The site's own hosts — the one in the root layout's
|
|
25
|
+
`metadataBase`, `www.` of it, and any named in `rscKit({ hosts })` — contribute
|
|
26
|
+
nothing, so the apex keeps path routing and an app adds tenants without moving
|
|
27
|
+
a file. `localhost` and an ip address are always the site's own.
|
|
28
|
+
|
|
29
|
+
Nothing to configure for that: `metadataBase` names the apex, and a
|
|
30
|
+
directory does the rest. `hosts` is for a name that is neither the apex nor a
|
|
31
|
+
subdomain of it and is still the site rather than a tenant — a staging or
|
|
32
|
+
internal name, or a second brand domain:
|
|
33
|
+
|
|
34
|
+
```ts title="vite.config.ts"
|
|
35
|
+
rscKit({ hosts: ['app.internal', 'example.co.uk'] })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Listing a subdomain there makes it the site by path instead of a tenant —
|
|
39
|
+
the "app on `app.example.com`, marketing on the apex" split — which is a
|
|
40
|
+
choice, not a requirement.
|
|
41
|
+
|
|
42
|
+
And only when a route could answer it: a `[domain]` directory at the top of
|
|
43
|
+
`app/`, or a directory named for the host. An app with a `metadataBase` and no
|
|
44
|
+
tenant tree routes every host by path, and a proxy that forwards to the app by
|
|
45
|
+
an internal name is not read as a tenant called `internal`.
|
|
46
|
+
|
|
47
|
+
The visitor's url is untouched: `acme.example.com/settings` stays in the
|
|
48
|
+
address bar, and a link to `/billing` on that page goes to
|
|
49
|
+
`acme.example.com/billing`. Only the match changed.
|
|
50
|
+
|
|
51
|
+
## A tenant tree
|
|
52
|
+
|
|
53
|
+
```tsx title="src/app/[domain]/layout.tsx"
|
|
54
|
+
import { notFound } from '@rsc-kit/core/not-found';
|
|
55
|
+
import { tenantByDomain } from '@/lib/tenants';
|
|
56
|
+
|
|
57
|
+
export default async function TenantLayout({ params, children }) {
|
|
58
|
+
const { domain } = await params;
|
|
59
|
+
const tenant = await tenantByDomain(domain); // "acme" or "acme.com", as stored
|
|
60
|
+
|
|
61
|
+
if (!tenant) notFound();
|
|
62
|
+
|
|
63
|
+
return <TenantProvider tenant={tenant}>{children}</TenantProvider>;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`[domain]` is an ordinary dynamic segment: `params.domain` in every page and
|
|
68
|
+
layout below it, `route('/[domain]/settings', { domain })` typed, `loading.tsx`
|
|
69
|
+
and `error.tsx` where you put them. A directory named for a host,
|
|
70
|
+
`app/admin/`, wins over `[domain]` the way a static segment wins over a
|
|
71
|
+
parameter anywhere else.
|
|
72
|
+
|
|
73
|
+
One difference from a parameter deeper in the tree: a `[domain]` at the top
|
|
74
|
+
of `app/` binds **only from a host**, never from a path. `example.com/nope`
|
|
75
|
+
is a 404, not a tenant called `nope`, and `acme.example.com/` cannot be
|
|
76
|
+
reached as `example.com/acme`. Next has no such guard — its `[domain]` folder
|
|
77
|
+
matches any path once the rewrite is in place — which is why Next apps tuck
|
|
78
|
+
the tenant tree under a route group.
|
|
79
|
+
|
|
80
|
+
## Domains in a database
|
|
81
|
+
|
|
82
|
+
`generateStaticParams` on the tenant route is the hook. The listed hosts are
|
|
83
|
+
rendered at build and stored, one file per host; a host added afterwards
|
|
84
|
+
falls through to the plain tree, or — if the page reads the request — renders
|
|
85
|
+
on demand and resolves at request time:
|
|
86
|
+
|
|
87
|
+
```ts title="src/app/[domain]/page.tsx"
|
|
88
|
+
export async function generateStaticParams() {
|
|
89
|
+
const tenants = await db.tenant.findMany({ select: { domain: true } });
|
|
90
|
+
|
|
91
|
+
return tenants.map((t) => ({ domain: t.domain }));
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A tenant's page that reads `cookies()` or awaits `connection()` is dynamic
|
|
96
|
+
for that tenant and stored for none, exactly as any page is.
|
|
97
|
+
|
|
98
|
+
## Locally
|
|
99
|
+
|
|
100
|
+
Keep `metadataBase` as the production host. `localhost` and an ip address
|
|
101
|
+
are always the site's own, so the dev server routes by path as it always
|
|
102
|
+
did, and nothing changes until a `[domain]` directory exists. To try a tenant
|
|
103
|
+
without DNS, send the host the router will see in production:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -H 'X-Forwarded-Host: acme.example.com' http://localhost:3000/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Or point `acme.example.com` at `127.0.0.1` in `/etc/hosts` and open it on
|
|
110
|
+
the dev server's port in a browser.
|
|
111
|
+
|
|
112
|
+
## Behind a proxy
|
|
113
|
+
|
|
114
|
+
The host is read from `X-Forwarded-Host` first, then `Host`. A load balancer
|
|
115
|
+
that terminates TLS and forwards to the app by an internal name still routes
|
|
116
|
+
by the name the visitor typed.
|
|
117
|
+
|
|
118
|
+
## Not for a static export
|
|
119
|
+
|
|
120
|
+
An export is served by a file server, which sees no host. Host routing is a
|
|
121
|
+
server feature; an exported site is the site's own on every host it is
|
|
122
|
+
served from.
|
|
123
|
+
|
|
124
|
+
## Coming from Next
|
|
125
|
+
|
|
126
|
+
Delete the rewrite in `middleware.ts` and the `[domain]` directory works as
|
|
127
|
+
it did; the segment binds the same value the rewrite put there. Next's
|
|
128
|
+
`rewrite()` for anything else is not here — a host maps to a tree by file,
|
|
129
|
+
not by code.
|
package/guides/emails.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Emails and other HTML
|
|
2
|
+
|
|
3
|
+
> Rendering React to HTML on the server — an email, a PDF, a feed — from a server action or a route, with "use ssr".
|
|
4
|
+
|
|
5
|
+
An email template is a React component, and `@react-email/render` (or
|
|
6
|
+
`renderToString` from `react-dom/server`) turns it into HTML. Call that from
|
|
7
|
+
a server action and this comes back:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Error: react-dom/server is not supported in React Server Components.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
React means it. Where server components render, `react` is a server-only
|
|
14
|
+
build: the renderer needs the client build's internals, and the components it
|
|
15
|
+
would render import that same `react` — no `useState`, no `useContext`. Nothing
|
|
16
|
+
that renders React to HTML can run there, and no alias fixes that. It has to
|
|
17
|
+
run in the **ssr** environment, which every app here already has: the one that
|
|
18
|
+
turns your pages into HTML for the browser.
|
|
19
|
+
|
|
20
|
+
## `"use ssr"`
|
|
21
|
+
|
|
22
|
+
Put the rendering — the template and the call that renders it — in a module
|
|
23
|
+
that starts with the directive:
|
|
24
|
+
|
|
25
|
+
```tsx title="src/lib/email/render.tsx"
|
|
26
|
+
"use ssr";
|
|
27
|
+
|
|
28
|
+
import { render } from '@react-email/render';
|
|
29
|
+
import { OtpEmail } from './otp-email';
|
|
30
|
+
|
|
31
|
+
export async function renderOtpEmail(code: string) {
|
|
32
|
+
const email = <OtpEmail code={code} />;
|
|
33
|
+
|
|
34
|
+
const [html, text] = await Promise.all([
|
|
35
|
+
render(email),
|
|
36
|
+
render(email, { plainText: true }),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
return { html, text };
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Everything else imports it normally:
|
|
44
|
+
|
|
45
|
+
```ts title="src/lib/email/send-otp.ts"
|
|
46
|
+
import { renderOtpEmail } from './render';
|
|
47
|
+
|
|
48
|
+
export async function sendOtpEmail(to: string, code: string) {
|
|
49
|
+
const { html, text } = await renderOtpEmail(code);
|
|
50
|
+
|
|
51
|
+
await transporter.sendMail({ to, subject: 'Your code', html, text });
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The module runs in the ssr environment, in the same process. Where server
|
|
56
|
+
components render, the build replaces it with proxies of its exports that call
|
|
57
|
+
across — the same thing `"use client"` does for a component, in the other
|
|
58
|
+
direction. In development that is a call into the ssr module runner; in a
|
|
59
|
+
build the module is part of the ssr bundle and the proxies import it from
|
|
60
|
+
there. Nothing to configure.
|
|
61
|
+
|
|
62
|
+
## The rules
|
|
63
|
+
|
|
64
|
+
**Exports are async functions.** A call crosses environments, so the answer is
|
|
65
|
+
a promise. A function declared without `async` is refused at build with its
|
|
66
|
+
name; so is a value, a class, `export { … }` or `export *`. Types are fine.
|
|
67
|
+
|
|
68
|
+
**Pass data across, not elements.** The template is created inside the module,
|
|
69
|
+
from the arguments — `renderOtpEmail(code)`, not `render(<OtpEmail />)` from
|
|
70
|
+
the caller. An element built where server components render carries
|
|
71
|
+
components from that side's `react`, and they would render with no hooks.
|
|
72
|
+
|
|
73
|
+
**The module's imports belong to the ssr side.** `@react-email/components`,
|
|
74
|
+
`react-dom/server`, a PDF renderer, a Markdown library that uses React — all
|
|
75
|
+
resolved and bundled for the ssr environment. A database client works there
|
|
76
|
+
too, but belongs on the calling side; keep the module to rendering.
|
|
77
|
+
|
|
78
|
+
## If you import it directly anyway
|
|
79
|
+
|
|
80
|
+
`react-dom/server` imported where server components render — through a
|
|
81
|
+
library, usually — now fails with the fix in the message rather than React's
|
|
82
|
+
refusal:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
react-dom/server cannot run where server components render … Imported by
|
|
86
|
+
src/lib/nodemailer.ts. Put the rendering — the template and the call — in a
|
|
87
|
+
module that starts with "use ssr" …
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A build prints the same once, as a warning, naming the file.
|
package/guides/errors.md
CHANGED
|
@@ -7,6 +7,15 @@ nearest one wins.
|
|
|
7
7
|
|
|
8
8
|
## When a page throws
|
|
9
9
|
|
|
10
|
+
With nothing of your own, a page that throws shows the engine's error page
|
|
11
|
+
where the page was, and the layouts around it stay: in development the
|
|
12
|
+
message and stack, in production "Something went wrong" with the digest to
|
|
13
|
+
search the server log for, and a **Try again** in both. It used to show
|
|
14
|
+
nothing — React unmounted the document on hydration, a black page with the
|
|
15
|
+
cause nowhere near it.
|
|
16
|
+
|
|
17
|
+
That page is the fallback. To show something of your own:
|
|
18
|
+
|
|
10
19
|
Put an `error.tsx` in the directory you want to cover:
|
|
11
20
|
|
|
12
21
|
```tsx title="src/app/orders/error.tsx"
|
|
@@ -48,14 +57,19 @@ Log the digest where you log the error, and the two line up.
|
|
|
48
57
|
### It does not catch everything
|
|
49
58
|
|
|
50
59
|
- **Errors in the layout above it.** The boundary sits inside that layout, so a
|
|
51
|
-
layout that throws needs an `error.tsx` a directory up.
|
|
60
|
+
layout that throws needs an `error.tsx` a directory up. The engine's own
|
|
61
|
+
page is outermost and catches those too.
|
|
52
62
|
- **The build.** A page that throws every time it renders fails the build
|
|
53
63
|
rather than shipping a stored error page. The boundary is for a request that
|
|
54
64
|
goes wrong, not a page that is broken.
|
|
55
65
|
|
|
56
66
|
## When nothing answers the url
|
|
57
67
|
|
|
58
|
-
`src/app/not-found.tsx` is rendered for any url no route matches
|
|
68
|
+
`src/app/not-found.tsx` is rendered for any url no route matches — and a
|
|
69
|
+
client-side navigation to one renders it in place, layout kept, url changed,
|
|
70
|
+
status still 404, the way Next.js does once the page is overridden. Without
|
|
71
|
+
the file, a navigation to a missing url is a full document load of a plain
|
|
72
|
+
"Not found":
|
|
59
73
|
|
|
60
74
|
```tsx title="src/app/not-found.tsx"
|
|
61
75
|
import Link from '@rsc-kit/core/Link'
|