@rsc-kit/mcp 0.14.0 → 0.16.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 +7 -0
- package/dist/answers.js +28 -0
- package/dist/answers.js.map +1 -1
- package/dist/bundleGuides.d.ts +27 -0
- package/dist/bundleGuides.js +138 -0
- package/dist/bundleGuides.js.map +1 -0
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +138 -17
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +13 -0
- package/dist/report.js +1 -1
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +168 -0
- package/guides/authorization.md +288 -0
- package/guides/caching.md +57 -0
- package/guides/coming-from-next.md +151 -0
- package/guides/connection.md +98 -0
- package/guides/edge-caching.md +159 -0
- package/guides/errors.md +109 -0
- package/guides/file-uploads.md +119 -0
- package/guides/fonts.md +117 -0
- package/guides/forms.md +528 -0
- package/guides/getting-started.md +132 -0
- package/guides/images.md +83 -0
- package/guides/index.json +187 -0
- package/guides/installation.md +338 -0
- package/guides/introduction.md +119 -0
- package/guides/mcp.md +113 -0
- package/guides/metadata.md +289 -0
- package/guides/navigation.md +84 -0
- package/guides/no-javascript.md +76 -0
- package/guides/offline.md +215 -0
- package/guides/ppr.md +181 -0
- package/guides/pwa.md +260 -0
- package/guides/queries.md +340 -0
- package/guides/quick-start.md +99 -0
- package/guides/react-compiler.md +153 -0
- package/guides/redirects.md +143 -0
- package/guides/response-headers.md +66 -0
- package/guides/route-interception.md +206 -0
- package/guides/routing.md +458 -0
- package/guides/sections.md +74 -0
- package/guides/server-actions.md +444 -0
- package/guides/static-generation.md +347 -0
- package/guides/testing.md +158 -0
- package/guides/third-party-scripts.md +105 -0
- package/guides/typed-routes.md +139 -0
- package/guides/url-validation.md +143 -0
- package/guides/validation.md +175 -0
- package/guides/view-transitions.md +120 -0
- package/package.json +4 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Redirects
|
|
2
|
+
|
|
3
|
+
> Leaving a page from inside the render, and what that costs.
|
|
4
|
+
|
|
5
|
+
```tsx title="src/app/products/[slug]/page.tsx"
|
|
6
|
+
import { redirect } from '@rsc-kit/core/redirect';
|
|
7
|
+
import { findProduct } from '../../../data';
|
|
8
|
+
|
|
9
|
+
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
10
|
+
const { slug } = await params;
|
|
11
|
+
const product = await findProduct(slug);
|
|
12
|
+
|
|
13
|
+
if (!product) redirect('/products');
|
|
14
|
+
|
|
15
|
+
return <h1>{product.name}</h1>;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`redirect` never returns — it throws, which is what stops the component. The
|
|
20
|
+
default status is `307`, because it preserves the method: a redirect out of a
|
|
21
|
+
`POST` does not silently become a `GET` of the target.
|
|
22
|
+
|
|
23
|
+
A redirect during a navigation stays a navigation. The document does not reload,
|
|
24
|
+
the layouts you are inside stay mounted, and only the part below them changes.
|
|
25
|
+
|
|
26
|
+
The url that redirected replaces its history entry rather than adding one, so
|
|
27
|
+
Back does not land on it and redirect you again.
|
|
28
|
+
|
|
29
|
+
## Where you call it matters
|
|
30
|
+
|
|
31
|
+
This is the part worth understanding, because it is also the security-relevant
|
|
32
|
+
part. Headers flush early on purpose — that is what makes the first paint fast
|
|
33
|
+
— so a redirect decided late has no status line left to use. There are two
|
|
34
|
+
windows, and nothing you write chooses between them:
|
|
35
|
+
|
|
36
|
+
| Called | Answered with | What the browser saw first |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Above every `<Suspense>` boundary | A real `3xx`, or `X-RSC-Redirect` on a navigation | Nothing at all |
|
|
39
|
+
| Inside a boundary | The shell, then the redirect | Layouts, and the fallbacks standing in for what never arrived |
|
|
40
|
+
|
|
41
|
+
Neither buffers the response. Before anything is written the host is still
|
|
42
|
+
waiting on the shell, so a component that redirects instead of rendering is
|
|
43
|
+
caught there. After that, React already carries an error digest to the client
|
|
44
|
+
and the destination rides along in it.
|
|
45
|
+
|
|
46
|
+
<Aside type="caution" title="Middleware belongs above the boundaries">
|
|
47
|
+
A `loading.tsx` wraps the whole page in `<Suspense>`. That is usually what you
|
|
48
|
+
want — it is how a page gets a frozen shell — but it also means an `await` at
|
|
49
|
+
the top of that page is *inside* a boundary, and a redirect after it arrives
|
|
50
|
+
in the second window.
|
|
51
|
+
|
|
52
|
+
Nothing from inside the boundary was shown, so a not-found redirect is fine
|
|
53
|
+
there. An authorization check is different: the layouts above it already
|
|
54
|
+
rendered and already went out. If a page must reveal nothing at all, the
|
|
55
|
+
check has to run somewhere that blocks the shell.
|
|
56
|
+
</Aside>
|
|
57
|
+
|
|
58
|
+
## Where to put an authorization check
|
|
59
|
+
|
|
60
|
+
In order of preference:
|
|
61
|
+
|
|
62
|
+
**In `middleware.ts`.** A file beside the layout, run before anything at or below it
|
|
63
|
+
renders, on every path. This is the one built for the job.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
<Aside type="danger" title="A layout is not a security boundary">
|
|
67
|
+
It is tempting — a layout renders above the page's boundary, so on a full
|
|
68
|
+
page load a redirect there happens before anything is sent. But a navigation
|
|
69
|
+
tells the server which layouts the client already has, in `X-RSC-Segments`,
|
|
70
|
+
and the server skips re-rendering those. A client that *claims* to hold your
|
|
71
|
+
layout skips the check:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout' /guarded
|
|
75
|
+
# 204, X-RSC-Redirect: /orders ← the middleware ran
|
|
76
|
+
|
|
77
|
+
curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded
|
|
78
|
+
# 200, and the page's content ← it did not
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The header is not verified and cannot be. Put the check in a `middleware.ts`
|
|
82
|
+
beside the layout instead — it runs before anything below it renders, on
|
|
83
|
+
every path. See [Authorization](/guides/authorization).
|
|
84
|
+
</Aside>
|
|
85
|
+
|
|
86
|
+
**At the top of a page with no `loading.tsx`.** Runs on every render of that
|
|
87
|
+
page, so it is not skippable the way a layout is — but fragile in a different
|
|
88
|
+
way: adding a `loading.tsx` later silently moves the check into the second
|
|
89
|
+
window, and nothing warns.
|
|
90
|
+
|
|
91
|
+
<Aside type="danger" title="Never catch it and continue">
|
|
92
|
+
`redirect` communicates by throwing. A `try`/`catch` that swallows everything
|
|
93
|
+
turns the redirect into a blank region — the component stops, and nothing
|
|
94
|
+
takes its place. If you must wrap the call, rethrow what you do not
|
|
95
|
+
recognise:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { isRedirectSignal } from '@rsc-kit/core/redirect';
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await mightRedirect();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (isRedirectSignal(error)) throw error;
|
|
104
|
+
// …
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
</Aside>
|
|
108
|
+
|
|
109
|
+
## From a server action
|
|
110
|
+
|
|
111
|
+
An action is not a render, so there is no shell to be on either side of. Throw
|
|
112
|
+
from the action and the client follows it:
|
|
113
|
+
|
|
114
|
+
```ts title="src/actions.ts"
|
|
115
|
+
'use server'
|
|
116
|
+
|
|
117
|
+
import { redirect } from '@rsc-kit/core/redirect';
|
|
118
|
+
|
|
119
|
+
export async function createPost(title: string) {
|
|
120
|
+
const post = await savePost(title);
|
|
121
|
+
|
|
122
|
+
redirect(`/posts/${post.slug}`);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## A route that only redirects
|
|
127
|
+
|
|
128
|
+
The redirect itself is stored, so it costs no render at all:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
○ /old-pricing (redirects to /pricing)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The build writes the status and the location, and the host answers from that
|
|
135
|
+
file — a document gets the status code, a navigation gets `X-RSC-Redirect` and
|
|
136
|
+
does it as an SPA navigation. Nothing is rendered per request, and a static
|
|
137
|
+
export can carry it.
|
|
138
|
+
|
|
139
|
+
## Loops
|
|
140
|
+
|
|
141
|
+
A navigation follows at most **8** redirects before throwing. A page that
|
|
142
|
+
redirects to itself is a mistake someone will make, and without a ceiling it is
|
|
143
|
+
an unbounded run of full renders rather than an error you can see.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Headers and cookies
|
|
2
|
+
|
|
3
|
+
> Setting response headers and cookies during a render.
|
|
4
|
+
|
|
5
|
+
## Reading
|
|
6
|
+
|
|
7
|
+
Anywhere in a server component or action:
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { headers, cookies, searchParams } from '@rsc-kit/core/request'
|
|
11
|
+
|
|
12
|
+
export default async function Page() {
|
|
13
|
+
const h = await headers()
|
|
14
|
+
const jar = await cookies()
|
|
15
|
+
|
|
16
|
+
return <p>Hello {jar.get('name')?.value ?? 'stranger'}</p>
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Writing
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { responseHeaders, cookies } from '@rsc-kit/core/request'
|
|
24
|
+
|
|
25
|
+
export async function middleware() {
|
|
26
|
+
responseHeaders().set('X-Frame-Options', 'DENY')
|
|
27
|
+
|
|
28
|
+
const jar = await cookies()
|
|
29
|
+
jar.set('last-seen', new Date().toISOString(), { httpOnly: true, path: '/' })
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Writing only works in middleware.** Middleware runs before the render, while
|
|
34
|
+
the response line has not been sent yet. A component runs *during* streaming,
|
|
35
|
+
when the headers are already on the wire — writing from one throws rather than
|
|
36
|
+
being silently dropped, so you find out immediately.
|
|
37
|
+
|
|
38
|
+
A redirect carries them too, which is what lets middleware remember where
|
|
39
|
+
someone was going before sending them to log in:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
export async function middleware() {
|
|
43
|
+
const jar = await cookies()
|
|
44
|
+
|
|
45
|
+
if (!jar.get('session')) {
|
|
46
|
+
jar.set('intended', '/dashboard', { path: '/' })
|
|
47
|
+
redirect('/login')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Cookie options
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
jar.set('name', 'value', {
|
|
56
|
+
httpOnly: true,
|
|
57
|
+
secure: true,
|
|
58
|
+
sameSite: 'lax', // 'strict' | 'lax' | 'none'
|
|
59
|
+
path: '/',
|
|
60
|
+
maxAge: 60 * 60 * 24,
|
|
61
|
+
expires: new Date('2027-01-01'),
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Names are validated as cookie tokens and `sameSite` / `expires` are checked, so
|
|
66
|
+
a typo is an error rather than a header the browser quietly ignores.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Route interception
|
|
2
|
+
|
|
3
|
+
> Opening a route as a modal over the page you were on.
|
|
4
|
+
|
|
5
|
+
Route interception lets you load a route from a different part of your app within the current layout. When a user clicks a link, the intercepted component renders in a parallel slot (like a modal) while the current page stays visible behind it. On hard navigation (refresh or direct URL), the normal page renders instead.
|
|
6
|
+
|
|
7
|
+
This follows the same convention as Next.js — using `(.)folder`, `(..)folder`, and `(...)folder` prefixes.
|
|
8
|
+
|
|
9
|
+
## Convention
|
|
10
|
+
|
|
11
|
+
| Prefix | Intercepts |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| (.)folder | Same level — matches a sibling route |
|
|
14
|
+
| (..)folder | One level up — matches a route in the parent segment |
|
|
15
|
+
| (...)folder | Root level — matches a route from the app root |
|
|
16
|
+
|
|
17
|
+
## A photo modal
|
|
18
|
+
|
|
19
|
+
The most common use case is showing content in a modal on SPA navigation, with the full page as a fallback on hard navigation.
|
|
20
|
+
|
|
21
|
+
### File Structure
|
|
22
|
+
|
|
23
|
+
```tsx title="File structure"
|
|
24
|
+
src/app/
|
|
25
|
+
├── layout.tsx ← renders {children} + {modal}
|
|
26
|
+
├── page.tsx ← feed page
|
|
27
|
+
├── @modal/
|
|
28
|
+
│ ├── default.tsx ← empty by default (no modal open)
|
|
29
|
+
│ └── (.)photo/[id]/
|
|
30
|
+
│ └── page.tsx ← photo modal (interceptor)
|
|
31
|
+
└── photo/[id]/
|
|
32
|
+
└── page.tsx ← full photo page (hard nav)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Root Layout
|
|
36
|
+
|
|
37
|
+
The layout receives the `modal` parallel slot as a prop. No special wrapper component needed — just render it directly:
|
|
38
|
+
|
|
39
|
+
```tsx title="app/layout.tsx"
|
|
40
|
+
export default function Layout({
|
|
41
|
+
children,
|
|
42
|
+
modal,
|
|
43
|
+
}: {
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
modal: React.ReactNode;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<div>
|
|
49
|
+
<main>{children}</main>
|
|
50
|
+
{modal}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Default Slot
|
|
57
|
+
|
|
58
|
+
The `@modal/default.tsx` renders when no interception is active:
|
|
59
|
+
|
|
60
|
+
```tsx title="app/@modal/default.tsx"
|
|
61
|
+
export default function ModalDefault() {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Interceptor Component
|
|
67
|
+
|
|
68
|
+
The interceptor at `@modal/(.)photo/[id]/page.tsx` receives the target route's params. It can be a server component — only the frame around it needs to be interactive:
|
|
69
|
+
|
|
70
|
+
```tsx title="app/@modal/(.)photo/[id]/page.tsx"
|
|
71
|
+
import { ModalShell } from '../../../components/ModalShell';
|
|
72
|
+
import { findPhoto } from '../../../../data';
|
|
73
|
+
|
|
74
|
+
export default async function PhotoModal({ id }: { id: string }) {
|
|
75
|
+
const photo = await findPhoto(id);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<ModalShell>
|
|
79
|
+
<h2>{photo.title}</h2>
|
|
80
|
+
<p>This renders in a modal on SPA navigation.</p>
|
|
81
|
+
</ModalShell>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Read the same data the full page reads. An interceptor pointed at a
|
|
87
|
+
different source than the page it stands in for looks exactly like a
|
|
88
|
+
broken interception: the modal opens correctly and says the record does
|
|
89
|
+
not exist.
|
|
90
|
+
|
|
91
|
+
### Closing It
|
|
92
|
+
|
|
93
|
+
The engine puts the interceptor in the slot; the affordances for dismissing it are yours to write. A modal needs three, and the last two are the ones that get forgotten — a close control, the `Escape` key, and a click on the backdrop:
|
|
94
|
+
|
|
95
|
+
```tsx title="components/ModalShell.tsx"
|
|
96
|
+
"use client";
|
|
97
|
+
|
|
98
|
+
import { useEffect, useRef } from 'react';
|
|
99
|
+
import type { ReactNode } from 'react';
|
|
100
|
+
|
|
101
|
+
export function ModalShell({ children }: { children: ReactNode }) {
|
|
102
|
+
const close = useRef<HTMLButtonElement>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
// The only way to dismiss a dialog without a pointer.
|
|
106
|
+
const onKey = (event: KeyboardEvent) => {
|
|
107
|
+
if (event.key === 'Escape') history.back();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
window.addEventListener('keydown', onKey);
|
|
111
|
+
close.current?.focus();
|
|
112
|
+
|
|
113
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="modal-backdrop" onClick={() => history.back()}>
|
|
118
|
+
{/* A click inside the dialog is not a click on the backdrop. */}
|
|
119
|
+
<article role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
|
120
|
+
<button ref={close} aria-label="Close" onClick={() => history.back()}>
|
|
121
|
+
×
|
|
122
|
+
</button>
|
|
123
|
+
{children}
|
|
124
|
+
</article>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Closing is `history.back()` rather than a link to a fixed URL. The modal was opened by pushing a history entry over the page beneath, so going back is both what the browser's own back button does and what returns to whatever page it was opened *over* — a hardcoded destination is wrong the moment the same modal is reachable from two places.
|
|
131
|
+
|
|
132
|
+
The router recognises that as leaving an interception and empties the slot without asking the server for anything. The page underneath never left, so closing costs no request and everything typed into it is still there.
|
|
133
|
+
|
|
134
|
+
A modal reachable from exactly one place can link to that place instead, and a real `<Link>` is worth keeping for it — a link can be opened in a new tab, and it still works before the page hydrates. `history.back()` is for a shell used from several.
|
|
135
|
+
|
|
136
|
+
### Full Page (Hard Nav)
|
|
137
|
+
|
|
138
|
+
When a user navigates directly to `/photo/123` (hard refresh, shared link), the normal page renders:
|
|
139
|
+
|
|
140
|
+
```tsx title="app/photo/[id]/page.tsx"
|
|
141
|
+
export default function PhotoPage({ id }: { id: string }) {
|
|
142
|
+
return (
|
|
143
|
+
<div>
|
|
144
|
+
<h1>Photo {id}</h1>
|
|
145
|
+
<p>Full photo page — shown on direct navigation or refresh.</p>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## How it works
|
|
152
|
+
|
|
153
|
+
On **SPA navigation** (clicking a Link):
|
|
154
|
+
|
|
155
|
+
- The client checks the intercept manifest (generated at build time)
|
|
156
|
+
- If the target URL matches an intercept pattern, headers are added to the request
|
|
157
|
+
- The server resolves the current page from the referer URL, to know which slot the interceptor belongs in
|
|
158
|
+
- It renders the interceptor *alone*, and says so with `X-RSC-Revalidate: modal`
|
|
159
|
+
- The client puts it in that slot. The page underneath is never re-rendered — so a half-filled form on it is still half-filled
|
|
160
|
+
|
|
161
|
+
On **hard navigation** (direct URL, refresh):
|
|
162
|
+
|
|
163
|
+
- Normal route matching — `/photo/123` renders `photo/[id]/page.tsx`
|
|
164
|
+
- No interception — the full photo page renders
|
|
165
|
+
|
|
166
|
+
Re-rendering the page underneath would put the modal on screen at the
|
|
167
|
+
cost of rebuilding everything below the layout that declares the slot.
|
|
168
|
+
That page is already mounted and still correct; only the slot is new.
|
|
169
|
+
|
|
170
|
+
## Nested interception
|
|
171
|
+
|
|
172
|
+
Use `(..)` to intercept routes one level up, or `(...)` to intercept from the app root:
|
|
173
|
+
|
|
174
|
+
```tsx title="Intercepting from a nested page"
|
|
175
|
+
// From /feed, intercept /photo/[id] at the same level
|
|
176
|
+
app/@modal/(.)photo/[id]/page.tsx
|
|
177
|
+
|
|
178
|
+
// From /feed/trending, intercept /photo/[id] one level up
|
|
179
|
+
app/feed/@modal/(..)photo/[id]/page.tsx
|
|
180
|
+
|
|
181
|
+
// From /dashboard/settings, intercept /photo/[id] from root
|
|
182
|
+
app/dashboard/settings/@drawer/(...)photo/[id]/page.tsx
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Several slots
|
|
186
|
+
|
|
187
|
+
You can intercept the same route into different slots:
|
|
188
|
+
|
|
189
|
+
```tsx title="File structure"
|
|
190
|
+
app/
|
|
191
|
+
├── @modal/(.)photo/[id]/page.tsx ← renders in "modal" slot
|
|
192
|
+
├── @preview/(.)photo/[id]/page.tsx ← renders in "preview" slot
|
|
193
|
+
└── photo/[id]/page.tsx ← full page
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Seeing it work
|
|
197
|
+
|
|
198
|
+
The example app in the repository has a working one: `@modal/(.)posts/[slug]`
|
|
199
|
+
over a feed of posts. Click a post to open it in a modal, then refresh to get
|
|
200
|
+
the same route as a page.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
Route interception builds on [parallel routes](/guides/routing). The `@folder`
|
|
205
|
+
convention is worth being comfortable with first — an interceptor is a page
|
|
206
|
+
inside a slot, and everything a slot does applies to it.
|