@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,159 @@
|
|
|
1
|
+
# Serving shells from a CDN
|
|
2
|
+
|
|
3
|
+
> Putting build-time shells on the edge, and what rsc-kit does not do.
|
|
4
|
+
|
|
5
|
+
A prerendered page is a file. So is a PPR shell. Both can sit on a CDN and be
|
|
6
|
+
served without touching your origin.
|
|
7
|
+
|
|
8
|
+
## What the build gives you
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
build/static/
|
|
12
|
+
about.html a whole page, frozen
|
|
13
|
+
posts/hello.html likewise
|
|
14
|
+
posts/[slug].ppr.html a shell — static parts frozen, holes still empty
|
|
15
|
+
about.flight the payload for a client-side navigation
|
|
16
|
+
about.seg1.flight the same, for a client already holding one layout
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`.html` is a finished page. `.ppr.html` is a shell: the layout, nav and
|
|
20
|
+
everything outside a `<Suspense>` boundary, with the fallbacks still in place.
|
|
21
|
+
Neither contains per-visitor data — they were rendered at build time, in no
|
|
22
|
+
request's context, which is what makes them safe for a shared cache.
|
|
23
|
+
|
|
24
|
+
## Caching them
|
|
25
|
+
|
|
26
|
+
The host already sends the right header:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Cache-Control: public, max-age=0, must-revalidate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`public` says a shared cache may store it. `max-age=0, must-revalidate` says
|
|
33
|
+
**check with the origin first**. So a CDN in front of this holds the bytes but
|
|
34
|
+
still asks every time — which saves bandwidth and nothing else.
|
|
35
|
+
|
|
36
|
+
To actually serve from the edge, add a cache rule for the paths you want held,
|
|
37
|
+
and give the CDN an edge TTL. On Cloudflare that is a Cache Rule with *Edge TTL
|
|
38
|
+
→ Override origin*.
|
|
39
|
+
|
|
40
|
+
**Do not blanket-cache the whole site.** A route with middleware is deliberately
|
|
41
|
+
sent as:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Cache-Control: private, no-store
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
because middleware runs per visitor — that is a page whose content depends on
|
|
48
|
+
who asked. A zone-wide "cache everything" rule overrides that and serves one
|
|
49
|
+
person's gated page to everyone. Scope the rule to the paths you know are
|
|
50
|
+
static.
|
|
51
|
+
|
|
52
|
+
Deployments are handled for you: cached responses carry a build version, so a
|
|
53
|
+
new deploy does not leave old shells being served against a new payload.
|
|
54
|
+
|
|
55
|
+
## Finishing a shell at the edge
|
|
56
|
+
|
|
57
|
+
A shell has holes in it, and something has to fill them. That happens at your
|
|
58
|
+
origin, into the same response — the shell is written first, then the
|
|
59
|
+
boundaries it could not finish.
|
|
60
|
+
|
|
61
|
+
You get this with no configuration: request a PPR route and the document you
|
|
62
|
+
receive already contains its dynamic content.
|
|
63
|
+
|
|
64
|
+
A small inline script from React moves each hole into place as the HTML parses.
|
|
65
|
+
So the content appears without waiting for the app bundle or for hydration — on
|
|
66
|
+
a slow connection, the difference between a spinner and a page. It also means
|
|
67
|
+
the content is in the HTML a crawler reads.
|
|
68
|
+
|
|
69
|
+
This is not the same as working without JavaScript: with scripting off, the
|
|
70
|
+
fallbacks stay.
|
|
71
|
+
|
|
72
|
+
To serve the shell itself from a CDN, two endpoints exist for an edge worker:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
GET /_rsc/ppr-shell?url=/dashboard the build-time shell, cacheable
|
|
76
|
+
POST /_rsc/ppr-resume?url=/dashboard the holes, for this visitor
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is a complete Cloudflare implementation in
|
|
80
|
+
[`examples/cloudflare-ppr-worker`](https://github.com/rsc-kit/rsc-kit/tree/main/examples/cloudflare-ppr-worker),
|
|
81
|
+
with no KV and no build step. The cache fills itself from the shell endpoint: a
|
|
82
|
+
miss goes to the origin while the shell warms behind it, and a hit streams the
|
|
83
|
+
shell then pipes the resumed holes onto the same response.
|
|
84
|
+
|
|
85
|
+
### What the response looks like on the wire
|
|
86
|
+
|
|
87
|
+
Measured on a deployed worker, for a page whose hole takes 2.5 s:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
headers 55 ms
|
|
91
|
+
first body byte 56 ms
|
|
92
|
+
shell heading 56 ms ← the page is on screen here
|
|
93
|
+
fallback markup 56 ms
|
|
94
|
+
hole content 2549 ms ← same response, no second request
|
|
95
|
+
stream complete 2549 ms
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Before this, the same document finished in 2 ms and contained no hole at all —
|
|
99
|
+
the content arrived later, on a separate payload fetch, after React had
|
|
100
|
+
hydrated.
|
|
101
|
+
|
|
102
|
+
**The trade is that the response stays open until the holes finish.** Previously
|
|
103
|
+
the document closed immediately and `load` fired early; now it fires when the
|
|
104
|
+
slowest boundary resolves. Nothing a visitor sees is slower — the shell paints
|
|
105
|
+
at the same moment either way — but page-level metrics that key on `load` will
|
|
106
|
+
read differently, and a proxy with a short response timeout needs to allow for
|
|
107
|
+
the whole render rather than just the shell.
|
|
108
|
+
|
|
109
|
+
## Guarded routes are never cached
|
|
110
|
+
|
|
111
|
+
The shell endpoint answers `404` for any route that declares middleware. Such a
|
|
112
|
+
page is not cacheable by a shared cache at all, so it never becomes a cache
|
|
113
|
+
entry — refused at the source rather than checked at the edge.
|
|
114
|
+
|
|
115
|
+
The resume endpoint runs that route's middleware against **the caller's own
|
|
116
|
+
cookies**, and refuses before rendering anything. An edge worker must therefore
|
|
117
|
+
forward the visitor's request rather than making one of its own; a resume asked
|
|
118
|
+
for with no cookies is an anonymous visitor and gets an anonymous answer.
|
|
119
|
+
|
|
120
|
+
## What stays on your origin
|
|
121
|
+
|
|
122
|
+
Next's PPR protocol hands the postponed blob to the CDN and takes it back on the
|
|
123
|
+
resume, which means the resume endpoint parses something an attacker can write.
|
|
124
|
+
That is the shape of a known denial-of-service against it.
|
|
125
|
+
|
|
126
|
+
Here the endpoint takes a **url**. The origin reads its own state from disk, and
|
|
127
|
+
a body posted to it is ignored. This is only possible because — unlike a generic
|
|
128
|
+
CDN — the origin already has the artifact, so there is nothing to hand out and
|
|
129
|
+
take back.
|
|
130
|
+
|
|
131
|
+
## When a CDN owns the response
|
|
132
|
+
|
|
133
|
+
Everything above rests on one fact: on this host, a per-visitor response head
|
|
134
|
+
can only come from middleware. `responseHeaders()` and `cookies().set()` throw
|
|
135
|
+
outside it, so a route that declares no middleware has no way to acquire one —
|
|
136
|
+
which is why "declares middleware" is a safe answer to "is this cacheable".
|
|
137
|
+
|
|
138
|
+
An auth proxy in front of the app breaks that, and quietly. One with sliding
|
|
139
|
+
expiry re-issues the session on an ordinary `200`, so the response leaving your
|
|
140
|
+
CDN carries a `Set-Cookie` this host never sent. Marked `public`, that is one
|
|
141
|
+
visitor's session handed to the next.
|
|
142
|
+
|
|
143
|
+
**Give any path your proxy covers a `middleware.ts`**, even an empty one. That
|
|
144
|
+
is what makes a route covered here, and covered routes are excluded from every
|
|
145
|
+
cache decision: `private, no-store` on the page, and refused outright by the
|
|
146
|
+
shell endpoint.
|
|
147
|
+
|
|
148
|
+
Two things worth knowing about this failure if you go looking for it.
|
|
149
|
+
|
|
150
|
+
You cannot detect this by inspecting the response. Nothing added a
|
|
151
|
+
`Vary: Cookie`, and looking for `Set-Cookie` only catches the cookie version —
|
|
152
|
+
an `x-user-id`, a CSRF token or a locale header leaks the same way.
|
|
153
|
+
|
|
154
|
+
The reliable question is structural: who owns the response head on this route?
|
|
155
|
+
|
|
156
|
+
It fails both ways. The loud one is a session leaking to the next visitor. The
|
|
157
|
+
quiet one is the reverse — an anonymous response cached first, then served to
|
|
158
|
+
someone who should have had a session, silently signing them out. The first gets
|
|
159
|
+
reported; the second looks like a flaky login.
|
package/guides/errors.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Errors and 404s
|
|
2
|
+
|
|
3
|
+
> What a visitor sees when a page throws, or asks for a url nothing answers.
|
|
4
|
+
|
|
5
|
+
Two files, both optional, both following the same rule as `loading.tsx`: the
|
|
6
|
+
nearest one wins.
|
|
7
|
+
|
|
8
|
+
## When a page throws
|
|
9
|
+
|
|
10
|
+
Put an `error.tsx` in the directory you want to cover:
|
|
11
|
+
|
|
12
|
+
```tsx title="src/app/orders/error.tsx"
|
|
13
|
+
'use client'
|
|
14
|
+
|
|
15
|
+
import type { RouteErrorProps } from '@rsc-kit/core/RouteErrorBoundary'
|
|
16
|
+
|
|
17
|
+
export default function OrdersError({ error, reset }: RouteErrorProps) {
|
|
18
|
+
return (
|
|
19
|
+
<section>
|
|
20
|
+
<h1>That did not work</h1>
|
|
21
|
+
<p>{error.message}</p>
|
|
22
|
+
<button type="button" onClick={reset}>Try again</button>
|
|
23
|
+
</section>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Anything below it that throws renders this instead. The layouts above stay on
|
|
29
|
+
screen — only the segment that failed is replaced.
|
|
30
|
+
|
|
31
|
+
`reset()` renders the segment again, for a failure that might not happen twice.
|
|
32
|
+
Navigating away clears it on its own.
|
|
33
|
+
|
|
34
|
+
:::caution[It has to be a client component]
|
|
35
|
+
`'use client'` at the top, or the build refuses it. Catching a render error is
|
|
36
|
+
a class component's job, it runs in the browser, and `reset` is a callback the
|
|
37
|
+
browser calls — none of which a server component can do.
|
|
38
|
+
:::
|
|
39
|
+
|
|
40
|
+
### What `error.message` says in production
|
|
41
|
+
|
|
42
|
+
React replaces it with a generic sentence and puts a hash on `error.digest`.
|
|
43
|
+
That is React, not this package: the real message could name a table, a query or
|
|
44
|
+
a path, and none of that should reach a browser.
|
|
45
|
+
|
|
46
|
+
Log the digest where you log the error, and the two line up.
|
|
47
|
+
|
|
48
|
+
### It does not catch everything
|
|
49
|
+
|
|
50
|
+
- **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.
|
|
52
|
+
- **The build.** A page that throws every time it renders fails the build
|
|
53
|
+
rather than shipping a stored error page. The boundary is for a request that
|
|
54
|
+
goes wrong, not a page that is broken.
|
|
55
|
+
|
|
56
|
+
## When nothing answers the url
|
|
57
|
+
|
|
58
|
+
`src/app/not-found.tsx` is rendered for any url no route matches:
|
|
59
|
+
|
|
60
|
+
```tsx title="src/app/not-found.tsx"
|
|
61
|
+
import Link from '@rsc-kit/core/Link'
|
|
62
|
+
|
|
63
|
+
export default function NotFound() {
|
|
64
|
+
return (
|
|
65
|
+
<main>
|
|
66
|
+
<h1>No such page</h1>
|
|
67
|
+
<p>Nothing answers that url. <Link href="/">Go home</Link>.</p>
|
|
68
|
+
</main>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
It renders through your root layout like any other page, and is served with a
|
|
74
|
+
real **404** — a page that says "not found" under a `200` is a page search
|
|
75
|
+
engines index.
|
|
76
|
+
|
|
77
|
+
An ordinary server component, so it can be `async` and read whatever it likes.
|
|
78
|
+
Without one, an unmatched url gets a plain `Not found` string.
|
|
79
|
+
|
|
80
|
+
### When the page has to decide
|
|
81
|
+
|
|
82
|
+
A url can match a route and still name nothing — `/posts/42` is a real route
|
|
83
|
+
and there may be no post 42. Only the page can know that, so the page says so:
|
|
84
|
+
|
|
85
|
+
```tsx title="src/app/posts/[slug]/page.tsx"
|
|
86
|
+
import { notFound } from '@rsc-kit/core/not-found'
|
|
87
|
+
|
|
88
|
+
export default async function PostPage({ params }) {
|
|
89
|
+
const post = await findPost((await params).slug)
|
|
90
|
+
|
|
91
|
+
if (!post) notFound()
|
|
92
|
+
|
|
93
|
+
return <article>{post.title}</article>
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Same page, same **404**. It throws, so nothing after it runs — and if you wrap
|
|
98
|
+
the call in a `try`/`catch`, rethrow what you do not recognise, or a missing
|
|
99
|
+
page becomes a blank region.
|
|
100
|
+
|
|
101
|
+
:::caution[Where you call it decides the status]
|
|
102
|
+
Called above every Suspense boundary — at the top of a page, in a layout —
|
|
103
|
+
nothing has been written yet, so the response is a real 404.
|
|
104
|
+
|
|
105
|
+
Called deeper, inside a boundary, the shell has already gone out with the
|
|
106
|
+
status line on it. The boundary shows its fallback instead and the status stays
|
|
107
|
+
`200`. If the status matters — and for anything a crawler or a cache sees, it
|
|
108
|
+
does — do the lookup above the boundary.
|
|
109
|
+
:::
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# File uploads
|
|
2
|
+
|
|
3
|
+
> Sending files through a server action without encoding them.
|
|
4
|
+
|
|
5
|
+
A `File` is a value a server action can take. React's Flight format carries it
|
|
6
|
+
as binary, so there is nothing to encode on the way out and nothing to decode
|
|
7
|
+
on the way in.
|
|
8
|
+
|
|
9
|
+
## One file
|
|
10
|
+
|
|
11
|
+
```tsx title="src/components/AvatarUpload.tsx"
|
|
12
|
+
"use client";
|
|
13
|
+
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
import { uploadAvatar } from '../actions';
|
|
16
|
+
|
|
17
|
+
export function AvatarUpload() {
|
|
18
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
async function submit(formData: FormData) {
|
|
21
|
+
const file = formData.get('avatar') as File;
|
|
22
|
+
|
|
23
|
+
if (!file || file.size === 0) return;
|
|
24
|
+
|
|
25
|
+
setUrl(await uploadAvatar(file));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<form action={submit}>
|
|
30
|
+
<input type="file" name="avatar" accept="image/*" />
|
|
31
|
+
<button type="submit">Upload</button>
|
|
32
|
+
{url && <img src={url} alt="" />}
|
|
33
|
+
</form>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The action receives the `File` itself — name, type, size and all:
|
|
39
|
+
|
|
40
|
+
```ts title="src/actions.ts"
|
|
41
|
+
'use server'
|
|
42
|
+
|
|
43
|
+
import { writeFile } from 'node:fs/promises';
|
|
44
|
+
|
|
45
|
+
export async function uploadAvatar(file: File): Promise<string> {
|
|
46
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
47
|
+
|
|
48
|
+
await writeFile(`./public/avatars/${file.name}`, bytes);
|
|
49
|
+
|
|
50
|
+
return `/avatars/${file.name}`;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Nothing about that is upload-specific: it is the same call, the same
|
|
55
|
+
serialisation and the same round trip as [any other server
|
|
56
|
+
action](/guides/server-actions).
|
|
57
|
+
|
|
58
|
+
## The whole form at once
|
|
59
|
+
|
|
60
|
+
An action can take the `FormData` instead of individual arguments, which is
|
|
61
|
+
usually simpler when the form mixes files and fields:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
"use client";
|
|
65
|
+
|
|
66
|
+
import { createPost } from '../actions';
|
|
67
|
+
|
|
68
|
+
export function NewPost() {
|
|
69
|
+
return (
|
|
70
|
+
<form action={createPost}>
|
|
71
|
+
<input name="title" />
|
|
72
|
+
<textarea name="body" />
|
|
73
|
+
<input type="file" name="cover" accept="image/*" />
|
|
74
|
+
<button type="submit">Create</button>
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts title="src/actions.ts"
|
|
81
|
+
'use server'
|
|
82
|
+
|
|
83
|
+
export async function createPost(formData: FormData) {
|
|
84
|
+
const title = formData.get('title') as string;
|
|
85
|
+
const cover = formData.get('cover') as File;
|
|
86
|
+
|
|
87
|
+
// …
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Several files
|
|
92
|
+
|
|
93
|
+
`formData.getAll` gives every file from a `multiple` input, and an array of
|
|
94
|
+
`File` serialises as readily as one:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
const files = formData.getAll('photos') as File[];
|
|
98
|
+
|
|
99
|
+
await uploadPhotos(files);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts title="src/actions.ts"
|
|
103
|
+
'use server'
|
|
104
|
+
|
|
105
|
+
export async function uploadPhotos(files: File[]) {
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
// …
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Size
|
|
113
|
+
|
|
114
|
+
The whole body is buffered before the action runs — it is one `POST`, not a
|
|
115
|
+
stream — so an upload occupies memory for as long as it takes. Two consequences
|
|
116
|
+
worth planning for:
|
|
117
|
+
|
|
118
|
+
- Keep any limit your host imposes as low as the app actually needs.
|
|
119
|
+
- For genuinely large files, do not send them through an action at all. Have the action mint a pre-signed URL and let the browser upload straight to storage; the bytes never touch your server.
|
package/guides/fonts.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Fonts
|
|
2
|
+
|
|
3
|
+
> Self-hosted fonts from npm, and what next/font was doing for you.
|
|
4
|
+
|
|
5
|
+
Install the font, import its CSS, name it in a variable. Vite does the rest.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @fontsource-variable/fraunces @fontsource-variable/geist
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```css title="src/app/styles.css"
|
|
12
|
+
@import '@fontsource-variable/fraunces/full.css';
|
|
13
|
+
@import '@fontsource-variable/fraunces/full-italic.css';
|
|
14
|
+
@import '@fontsource-variable/geist';
|
|
15
|
+
|
|
16
|
+
:root {
|
|
17
|
+
--font-display: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
|
|
18
|
+
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
19
|
+
'Helvetica Neue', Arial, sans-serif,
|
|
20
|
+
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Put your font in *front* of a full stack rather than in place of one. Two things
|
|
25
|
+
the rest of the stack does that a bare `sans-serif` does not:
|
|
26
|
+
|
|
27
|
+
- **Emoji.** Geist has no emoji glyphs, so an emoji falls through to whatever is
|
|
28
|
+
named after it. With nothing there, some Linux and older Android setups draw a
|
|
29
|
+
monochrome glyph or a box. The four emoji fonts at the end are consulted only
|
|
30
|
+
for characters the main font cannot draw, so they cost nothing.
|
|
31
|
+
- **A closer fallback.** During the `swap` window the browser shows the next
|
|
32
|
+
font. `-apple-system` and `Segoe UI` are nearer Geist in width and x-height
|
|
33
|
+
than Helvetica or Arial, so the moment of swap is a smaller jolt.
|
|
34
|
+
|
|
35
|
+
Those are Tailwind's own defaults, and shadcn's generated `--font-sans:
|
|
36
|
+
'Geist Variable', sans-serif` drops both. Keep the stack.
|
|
37
|
+
|
|
38
|
+
[Fontsource](https://fontsource.org) packages every Google font — and many
|
|
39
|
+
others — as woff2 files with the `@font-face` rules already written. Vite
|
|
40
|
+
hashes the files and serves them beside your other assets, so **nothing is
|
|
41
|
+
fetched from Google at runtime**, and nothing is downloaded at build time
|
|
42
|
+
either. The files are in `node_modules`.
|
|
43
|
+
|
|
44
|
+
## Porting from `next/font`
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// before
|
|
48
|
+
const fraunces = Fraunces({
|
|
49
|
+
variable: '--font-display',
|
|
50
|
+
subsets: ['latin'],
|
|
51
|
+
style: ['normal', 'italic'],
|
|
52
|
+
axes: ['SOFT', 'opsz'],
|
|
53
|
+
display: 'swap',
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every option there is something Fontsource already did:
|
|
58
|
+
|
|
59
|
+
| `next/font` | Fontsource |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `variable: '--font-display'` | the `:root` rule above — you write the variable, once |
|
|
62
|
+
| `subsets: ['latin']` | every subset ships, each with a `unicode-range`; the browser fetches only the ones the page uses |
|
|
63
|
+
| `style: ['normal', 'italic']` | `full.css` and `full-italic.css` |
|
|
64
|
+
| `axes: ['SOFT', 'opsz']` | `full.css` — every axis of the variable font; `standard.css` is weight alone |
|
|
65
|
+
| `display: 'swap'` | already in every rule |
|
|
66
|
+
| `className={fraunces.variable}` | nothing — the variable is on `:root` |
|
|
67
|
+
|
|
68
|
+
So the layout loses its font imports and the `className` gains nothing:
|
|
69
|
+
|
|
70
|
+
```tsx title="src/app/layout.tsx"
|
|
71
|
+
import './styles.css'
|
|
72
|
+
|
|
73
|
+
export default function RootLayout({ children }) {
|
|
74
|
+
return (
|
|
75
|
+
<html lang="en" className="h-full antialiased dark font-sans">
|
|
76
|
+
…
|
|
77
|
+
</html>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The subsetting is worth a second look, because it is *better* than what you
|
|
83
|
+
had. `next/font` asks you to name subsets at build time and ships those.
|
|
84
|
+
Fontsource ships all of them, each behind a `unicode-range`, and the browser
|
|
85
|
+
downloads only the ones a page's text actually needs — a Latin-only page
|
|
86
|
+
fetches one file whatever else is installed.
|
|
87
|
+
|
|
88
|
+
## Preloading
|
|
89
|
+
|
|
90
|
+
The one thing `next/font` did that needs a line here. It added a `<link
|
|
91
|
+
rel="preload">` for each font so the browser finds it before the stylesheet
|
|
92
|
+
does. Without one, the font is discovered when the CSS is parsed, and the text
|
|
93
|
+
shows in the fallback face for a moment longer.
|
|
94
|
+
|
|
95
|
+
Import the file for its url, and React hoists the link:
|
|
96
|
+
|
|
97
|
+
```tsx title="src/app/layout.tsx"
|
|
98
|
+
import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'
|
|
99
|
+
|
|
100
|
+
export default function RootLayout({ children }) {
|
|
101
|
+
return (
|
|
102
|
+
<html>
|
|
103
|
+
<head>
|
|
104
|
+
<link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
|
|
105
|
+
</head>
|
|
106
|
+
…
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`?url` is Vite's — it hands back the hashed path the build will serve. Preload
|
|
110
|
+
the one file the first paint needs, usually the Latin regular; preloading all
|
|
111
|
+
of them defeats the subsetting.
|
|
112
|
+
|
|
113
|
+
## A font that is not on npm
|
|
114
|
+
|
|
115
|
+
A file you own goes in `public/` and gets its own `@font-face` in your CSS, the
|
|
116
|
+
same as anywhere. Or in `src/` and imported with `?url` as above, which hashes
|
|
117
|
+
it.
|