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, and whether to include Tailwind. Every answer has a flag, so it also runs unattended:
bun create rsc-kit@latest my-app --host=bun --compiler=oxc --tailwindWhat 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 and Vite 8 are peer dependencies — the versions the RSC build
needs, not ones this package pins for you.
Configure Vite
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { nitro } from 'nitro/vite'
import { rscKit } from '@rsc-kit/core/vite'
/**
* 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',
viewTransitions: true,
offline: true,
}),
react(),
],
})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>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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": "vite",
"build": "vite build",
"start": "bun .output/server/index.mjs",
"compile": "bun build --compile .output/server/index.mjs --outfile my-app"
}
}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. Turn it off with
rscKit({ prerender: false }).
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",
"jsx": "react-jsx",
"strict": true,
"noEmit": 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.
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.
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
PUBLIC_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,
});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.