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:
bun create rsc-kit@latest my-appIt 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). Every answer has a flag, so it
also runs unattended:
bun create rsc-kit@latest 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
npm i @rsc-kit/core react react-domyarn add @rsc-kit/core react react-dompnpm add @rsc-kit/core react react-dombun add @rsc-kit/core react react-domnpm i -D vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-domyarn add -D vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-dompnpm add -D vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-dombun add -d vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-dom@rsc-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.
Configure Vite
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' } },
// On every page, and still served from an edge: see the edge contract.
headers: { 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' },
}),
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.
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.
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: '@rsckit',
},
}
// `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
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 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/:
npm run build
npm run startWhere it runs is the preset in vite.config.ts — see Where it
runs.
Scripts
{
"type": "module",
"scripts": {
"dev": "bun --bun vite",
"build": "bun --bun vite build",
"start": "bun .output/server/index.mjs",
"compile": "bun --bun vite build && bun run compile:binary",
"compile:binary": "bun build --compile --splitting .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 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.
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.
Then:
npm run build # bundles, and freezes every page it can
npm run startWorth 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
{
"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:
interface ViteTypeOptions {
// Turns an unknown import.meta.env key into an error instead of `any`.
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}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.
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 works
unchanged, with any Standard Schema validator:
import { createEnv } from '@t3-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 makesLinkandredirect()typedrsc-env.d.ts— the host globalrsc-engine.d.ts— the generated bundle
Only what has to be generated is. Anything you can import, you import —
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:
{ "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.