rsc-kit

React Server Components as a Vite plugin · v0.20

The build tells you the truth.

Every route is rendered at build time. What is not static says why. What cannot be right is refused, with the fix named. Nothing ships JavaScript until you write "use client" — this page never did. It is also exactly what an agent reads instead of guessing.

bun create rsc-kit@latest my-app
Quick start
$ vite build
  1. ○/no jsno client components, so ships no javascriptthis page
  2. ○/orders86 kB
  3. ◐/locale86 kBdynamic — called cookies(), headers()
  4. ◐/dashboard85 kBdata took longer than the build budget
  5. ◐/posts/_slug_85 kBone shell for every url — add generateStaticParams to store each
  6. ƒ/guarded/api/secretguarded by middleware
  7. ✗/accountreads the request before anything can paint. Add a loading.tsx beside it, or put a <Suspense> above the waiting, and it has a skeleton to store.
⚠2 actions run no middleware: addToTotal, placeOrder (src/actions.ts)
Nothing checks who calls them. Fine for a public one; otherwise build it from an action client, so the check cannot be forgotten.
6 kBgzipped on top of React
0 kBon a page with nothing to hydrate
1 stringto move from a container to Workers
01Static

Nothing is dynamic by declaration

There is no export const dynamic. A page is frozen at build time unless it reads the request, and the build prints which read did it. A page that blocks above every <Suspense> is refused with the fix named, not stored blank. All of it lands in a build-report.json a CI step can assert on.

Static generation →
○  /pricing               84 kB
◐  /account               85 kB
   dynamic — called cookies()
✗  /orders
   reads the request before anything can paint.
   Add a loading.tsx beside it, or a <Suspense> above.
02Agent-native

The structure is exposed, and the guardrails are real

Not a framework that builds your app for you — one an agent can build correctly on. The build refuses what cannot be right. Every project ships an AGENTS.md for the rules that compile either way, a .mcp.json to a server that answers from the last build with every guide bundled, and tests that hit the deployed handler with no port and no browser. bun run check is the whole loop.

Working with an agent →
> list_routes
15 static, 5 partial prerender, 2 dynamic
(from the last build, just now)

/locale   86 kB  — a stored shell, the rest per request
    dynamic — called cookies(), headers()

actions: 8, 6 built from an action client
2 run NO middleware: addToTotal, placeOrder

> how_to({ topic: 'forms' })
> read_guide({ slug: 'server-actions' })
03Actions

Failures come back, not thrown across the wire

createActionClient() chains middleware with typed ctx, validates with any Standard Schema, and returns what went wrong — React strips a thrown message in production, and the field it named goes with it. revalidate('posts') re-renders one named section and sends it back with the action's own answer: one request. The build lists every action not built this way.

Server actions →
export const createPost = client
  .input(schema)
  .handler(async ({ input, ctx, fieldErrors }) => {
    if (await slugTaken(input.slug))
      return fieldErrors({ slug: 'Already taken' })
    revalidate('posts')
    return save(input, ctx.user)
  })
// → { data } | { validationErrors } | { serverError }
04Forms

Works before hydration, and after

A real form: it submits without JavaScript, then upgrades. The schema runs in the browser and again in the action; field errors land on the field. Uncontrolled by default, and read with two words: error() for a field's error, field() to bind one. shadcn's Field fits as it is.

Forms →
<Form action={createPost} schema={schema}>
  {({ pending, error }) => (
    <>
      <input name="title" />
      {error('title') && <p>{error('title')}</p>}
      <button disabled={pending}>Save</button>
    </>
  )}
</Form>
// a real <form action>: submits with no JS, then upgrades
05Types

Typed all the way to the link

An href that no route answers stops compiling. Export a searchParams schema beside a page and the values arrive parsed — and the same schema types every <Link search> to it. Bad params are a 404, a bad query reaches the error boundary, a bad body is a 422.

Typed routes →
<Link href="/search" search={{ q: 'shoes', page: 2 }} />

<Link href="/search" search={{ page: '2' }} />
                              ~~~
Type 'string' is not assignable to type 'number'.

<Link href="/serach" />
           ~~~~~~~~~
'/serach' is not a route this app answers.
06Deploy

Where it runs is one string

There is no server file. Nitro builds one around the route tree, so the target is a preset, not an adapter package to wait for. Or bun build --compile into one binary. Tailwind, PostCSS and any Vite plugin work the way they do in any Vite app.

Deployment →
nitro({ preset: 'bun' })
nitro({ preset: 'node' })
nitro({ preset: 'cloudflare_module' })   ← this page
nitro({ preset: 'vercel' })
nitro({ preset: 'netlify' })
nitro({ preset: 'deno_deploy' })
// same route tree, same build output, same report
07Honest

Where it stands

Version 0.20, and the API is not frozen. For a content site with forms on Bun or Workers, it is ready today. For a large team's product, Next is still the safe answer, and this page will say so until it is not.

Coming from Next.js →