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, 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 --tailwind

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 and Vite 8 are peer dependencies — the versions the RSC build needs, not ones this package pins for you.

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'

/**
 * 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.

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>
        <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

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": "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 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",
    "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:

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 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:

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,
});

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