@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
416 lines (340 loc) • 15.9 kB
Markdown
# Installation
> From an empty directory to a streaming RSC app.
There is no scaffolding command. An app is a Vite project with one plugin and
one request handler, so this is the whole of it.
## Create an app
One command, and it asks the rest:
```sh
bun create rsc-kit my-app
```
It asks where it will run (Bun, Node or Cloudflare Workers), whether you want the
React Compiler and which implementation, whether to include Tailwind and, if
so, shadcn/ui, and
whether the app should be installable — offline, a manifest and starter icons
([`--pwa`](/guides/pwa#starting-one-installable)). Every answer has a flag, so it
also runs unattended:
```sh
bun create rsc-kit my-app --host=bun --compiler=oxc --tailwind
```
| Flag | What it does |
| --- | --- |
| `--host=bun\|node\|worker` | Where it runs, which picks the Nitro preset. |
| `--compiler=none\|oxc\|babel` | The React Compiler. The prompt offers oxc. |
| `--tailwind`, `--no-tailwind` | Include Tailwind. |
| `--lint`, `--no-lint` | Include oxlint. |
| `--validation=zod\|valibot\|arktype\|none` | The schema library forms, actions and env use. |
| `--env`, `--no-env` | Typed environment variables. |
| `--pwa`, `--no-pwa` | Installable: offline, a manifest and starter icons. Off by default. |
| `--shadcn` | shadcn/ui, set up for RSC. Needs Tailwind. See below. |
| `--source-dir <dir>` | Where `app/` lives. Default `src`. |
| `--backend=<url>` | A backend answering `rpc()`, such as a Go server. Writes `.env` with a secret. |
| `--no-install`, `--no-git` | Skip installing dependencies, or `git init`. |
| `--yes` | Accept every default and ask nothing. |
shadcn/ui is asked about only after Tailwind is chosen, because its
components are styled with it; `--shadcn` with `--no-tailwind` is refused.
Either way, it runs shadcn's own `init` once dependencies are installed, so it
asks its own questions, such as the component library; with `--yes` it takes
its defaults. Then it sets `"rsc": true` in `components.json`. shadcn sees a
plain Vite project and writes `false`, and the components it adds that need
the browser, a dialog or a dropdown, would then lack `"use client"`. In an
existing app, `rsc-kit init` asks the same question when the project has or
adds Tailwind, and when `components.json` is already there it asks only
whether to set the flag. Running `shadcn init` yourself
leaves it `false`; set it to `true` by hand.
Use `--yes`, not `-y`, with `bun create`: Bun reads `-y` as its own flag and
refuses it. `bunx create-rsc-kit my-app -y` accepts either.
What comes out builds, prerenders, typechecks and serves before you edit it.
The rest of this page is for adding rsc-kit to an app you already have.
## Install
<PackageManagers pkg="@rsc-kit/core react react-dom" />
<PackageManagers pkg="vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-dom" dev />
`-kit/core` brings the Vite plugin, the render engine and the client
runtime. React 19.2 or later and Vite 8 are peer dependencies — the versions
the RSC build needs, not ones this package pins for you. 19.2 is where
`useEffectEvent` arrived, which the live-data hooks are built on.
<Aside type="note" title="What @vitejs/plugin-react is for">
Fast Refresh: edit a client component and React keeps its state, instead of
the page reloading and losing it. Put it *after* `rscKit()`.
</Aside>
## Configure Vite
```ts title="vite.config.ts"
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { rscKit } from "@rsc-kit/core/vite";
import { fileURLToPath } from "node:url";
/**
* The full route tree, built the way every scaffolded app is built.
*
* Nitro owns the server and the entry is generated — there is no server file
* in this directory, which is the point.
*/
export default defineConfig({
plugins: [
nitro({ preset: "bun", serveStatic: "inline" }),
rscKit({
sourceDir: "src",
outDir: "build",
offline: true,
openapi: { info: { title: 'Example API', version: '1.0.0' } },
}),
react(),
],
// `@/thing` for `src/thing`, the same alias tsconfig.json declares in
// `paths`: both, or the import type-checks and then fails to resolve.
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
```
`sourceDir` is where your route tree lives. The defaults are plain Vite ones —
`src/app` in, `dist/client` and `.rsc` out — so a project that adopts them can
call `rscKit()` with nothing at all.
<Aside type="note" title="Add your own plugins after it">
`rscKit()` includes `/plugin-rsc`, which has to see modules before
any React layer transforms them. Tailwind, the React Compiler and anything
else go after it in the array.
</Aside>
## Write a root layout
The root layout renders the **whole document** — there is no separate HTML
template, and the build injects the bootstrap script and stylesheet links into
what you return here.
```tsx title="src/app/layout.tsx"
import './styles.css'
// Preloaded so the browser finds it before the stylesheet does. ?url is Vite's
// and hands back the hashed path the build serves.
import frauncesLatin from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'
import type { ReactNode } from 'react'
import { Nav } from '../components/Nav'
import type { Metadata } from '@rsc-kit/core/metadata'
export const metadata: Metadata = {
title: { template: '%s · rsc-kit', default: 'rsc-kit' },
description: 'React Server Components as a Vite plugin',
// Once, here. A share-card scraper needs an absolute image url, and this is
// what turns the opengraph-image.png in app/ into one.
metadataBase: new URL('https://example.rsc-kit.dev'),
openGraph: {
siteName: 'rsc-kit example',
type: 'website',
},
twitter: {
card: 'summary_large_image',
site: '',
},
}
// `modal` is a parallel slot: the @modal directory beside this file fills it.
// It renders alongside children, not instead of them.
export default function RootLayout({ children, modal }: { children: ReactNode; modal?: ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preload" href={frauncesLatin} as="font" type="font/woff2" crossOrigin="anonymous" />
</head>
<body>
<Nav />
<main>{children}</main>
{modal}
</body>
</html>
)
}
```
## Write a page
```tsx title="src/app/page.tsx"
export default function HomePage() {
return <h1>Hello</h1>;
}
```
`app/page.tsx` serves `GET /`, wrapped in `app/layout.tsx`. Nothing registers
it — the plugin reads the directory at build time. See
[Routing](/guides/routing) for the rest of the conventions.
## Serve it
There is no server file to write. Nitro builds one around the route tree, and
`npm run build` leaves it in `.output/`:
```sh
npm run build
npm run start
```
Where it runs is the preset in `vite.config.ts` — see [Where it
runs](/hosts/where-it-runs).
## Scripts
```json title="package.json"
{
"type": "module",
"scripts": {
"dev": "bun --bun vite",
"build": "bun --bun vite build",
"start": "bun .output/server/index.mjs",
"compile": "bun --bun vite build && bun build --compile .output/server/compile.mjs --outfile dist/app"
}
}
```
`bun --bun` runs Vite on Bun's runtime. The `vite` bin has a Node shebang, so
`bun run dev` alone would start the dev server, the build and the prerender
under Node — and a project importing `bun` or `bun:sqlite` fails at the first
render. On Node the scripts are plain `vite` and `vite build`;
[Running on Bun](/hosts/bun) has the rest.
`build` freezes every page it can, so there is no separate prerender step and
no command for one — freezing runs the app, which needs the bundle the build
just wrote, and only the build knows where that is. A page that must render
per request says so with `await connection()`; there is no switch for the app.
`start` runs what the build wrote. There is no server file in a scaffolded app —
Nitro builds the server around the route tree, and where it runs is a preset in
`vite.config.ts`. See [Where it runs](/hosts/where-it-runs).
`compile` produces a standalone binary containing the engine, the route tree,
the frozen pages **and** the assets. That last part is `serveStatic: 'inline'`
in the vite config; without it the binary serves pages and 404s every asset.
<Aside type="note" title="Do not set NODE_ENV">
The build stamps its mode into the bundle, so `npm run start` is
production because it was *built* that way. Setting `NODE_ENV` yourself adds a
second source of truth that can disagree — and when it disagrees, every page
renders and none of them become interactive.
For a development build with React's real error messages, use
`vite build --mode development`.
</Aside>
Then:
```bash
npm run build # bundles, and freezes every page it can
npm run start
```
Worth adding a `typecheck` script beside them (`tsc --noEmit`) and running it
in CI. Server components are ordinary functions and pages are ordinary
modules, so the typechecker sees the whole route tree — a page whose props do
not match its `[param]` segment is a compile error rather than a blank page.
## TypeScript
```json title="tsconfig.json"
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["@types/bun", "vite/client"]
},
"include": ["src/**/*", "server/**/*", ".rsc-kit/**/*", "vite.config.ts"]
}
```
`vite/client` is doing real work in that list: without it a side-effect import
of a stylesheet (`import './styles.css'` in the root layout) is an error, and
`import.meta.env` is untyped.
Three of those are worth keeping even if you trim the rest.
`verbatimModuleSyntax` makes a type-only import say so, which is what keeps a
`type` import of a client component from pulling the component into the server
bundle. `isolatedModules` and `moduleDetection` are what a per-file transpiler
like Vite's assumes. And `paths` pairs with `resolve.alias` in `vite.config.ts`
— `@/thing` for `src/thing` — which some tools (shadcn's `init`, for one) check
for before they will run.
### Environment variables
There is nothing to install and nothing this package adds — Vite already owns
this. Two rules and one declaration.
Anything named `VITE_*` is **inlined into the client bundle** and ships to
the browser, whether or not a browser file reads it. Everything else stays on
the server, read through `process.env`.
The prefix is the whole boundary, so never put a secret behind it.
`VITE_STRIPE_KEY` is a published key. It is Vite's own prefix and the only
one: a `PUBLIC_*` variable, the spelling this package once accepted beside
it, is refused by the build with its name, because the alternative was a
value that reads as `undefined` in the browser and nowhere else.
And one line never to write into a `.env`: `NODE_ENV`. Vite sets it itself —
`development` under `vite`, `production` under `vite build` — and honours a
`.env` that sets it, so `NODE_ENV=development` in `.env` turns `vite build`
into a development build: the pages compile against React's development JSX
runtime, the server bundles carry React's production build, and every route
fails to render with React's opaque "message omitted in production builds".
The build refuses that up front and names the file and line, because a
plugin cannot override it — Vite applies the `.env` value after plugins have
run. If another tool in the repository wants the line, keep it in that
tool's own `.env`, not the app's; the env schema still validates
`NODE_ENV` from what the runtime sets.
Declare the ones you use, and Vite types them:
```ts title="src/env.d.ts"
interface ViteTypeOptions {
// Turns an unknown import.meta.env key into an error instead of `any`.
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
```
<Aside type="caution" title="On Bun, a misspelled variable still compiles">
`/bun` declares its own `ImportMetaEnv`, which cancels out the strictness
Vite's option adds. You still get autocomplete and `string` instead of
`string | undefined` — but a typo is `any`, not an error.
</Aside>
#### When they are read
`import.meta.env.VITE_*` is a literal in the bundle: changing one means a
rebuild. `process.env.*` is a live read, so the deploy's environment wins —
with one exception the build makes for you. A route that reads an environment
variable and nothing request-bound is frozen at build time, value included,
because nothing marked it as needing a request. That is the right answer for
`VITE_SITE_NAME` and the wrong one for a feature flag flipped per deploy. For
the second kind, read it after `await connection()`, which keeps the route on
the server. See [Static generation](/guides/static-generation).
#### Validating them
Vite checks the prefix and nothing else: a missing `DATABASE_URL` is
`undefined` until the first query fails. There is nothing native for this
because nothing native is needed — [t3-env](https://env.t3.gg/docs/core) works
unchanged, with any Standard Schema validator:
```ts title="src/env.ts"
import { createEnv } from '-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: { DATABASE_URL: z.string().url() },
clientPrefix: 'VITE_',
client: { VITE_API_URL: z.string().url() },
runtimeEnv: typeof process === 'undefined' ? import.meta.env : { ...import.meta.env, ...process.env },
emptyStringAsUndefined: true,
});
```
`typeof process === 'undefined'` is not decoration: a `"use client"` file
that imports this for a `VITE_` value runs where there is no `process`,
and `{ ...process.env }` there is a `ReferenceError` before the first
render. The scaffold's `env.ts` is written this way.
Read `env.DATABASE_URL` instead of `process.env.DATABASE_URL` and three things
follow. It is a `string`, not `string | undefined`. A server variable touched
from a client component throws by name rather than being silently `undefined`.
And because the module validates when it is first imported, a missing variable
fails the **build** — the prerender imports it — instead of the first request
in production.
The build writes its declaration files into `.rsc-kit/` at the project root,
not among your own source. Three of them:
- `rsc-routes.d.ts` — the routes it found, which is what makes `Link` and
`redirect()` [typed](/guides/routing#typed-links)
- `rsc-env.d.ts` — the host global
- `rsc-engine.d.ts` — the generated bundle
Only what has to be generated is. Anything you can import, you import —
[`Metadata`](/guides/metadata/) comes from the package and works before you have
built anything.
These are ambient, which needs only that they are inside the project and that
the typechecker is told the directory exists:
```json title="tsconfig.json"
{ "include": ["src/**/*", ".rsc-kit/**/*"] }
```
A scaffolded app has that line, and `rsc-kit init` adds it. Leave it out and
the build says so — otherwise typed routes fall back to plain `string` and
nothing else would tell you.
Add `.rsc-kit/` to `.gitignore`.
## Where next
<CardGrid>
<Card title="Routing" icon="ph:tree-structure">
[Pages, layouts, slots and navigation →](/guides/routing)
</Card>
<Card title="Server actions" icon="ph:lightning">
[Calling the server from a client component →](/guides/server-actions)
</Card>
<Card title="Static generation" icon="ph:snowflake">
[Rendering ahead of time, and exporting →](/guides/static-generation)
</Card>
</CardGrid>