Skip to content

Installation

From an empty directory to a streaming RSC app.

Updated View as Markdown

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-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). 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-dom
npm i -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

vite.config.tsts
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.

src/app/layout.tsxtsx
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

src/app/page.tsxtsx
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 start

Where it runs is the preset in vite.config.ts — see Where it runs.

Scripts

package.jsonjson
{
  "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 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

tsconfig.jsonjson
{
  "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:

src/env.d.tsts
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:

src/env.tsts
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 makes Link and redirect() typed
  • 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 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:

tsconfig.jsonjson
{ "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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close