Skip to main content

React Server Components

React Router provides experimental support for React Server Components (RSC), enabling you to render components on the server and stream them to the client.
RSC support in React Router is experimental and APIs may change. Not recommended for production use.

Overview

React Server Components allow you to:
  • Render components on the server
  • Stream components to the client
  • Access server-only resources (databases, file system)
  • Reduce client bundle size
  • Improve initial page load

Setup

RSC Framework Mode

Use the unstable_reactRouterRSC plugin:
// vite.config.ts
import { defineConfig } from "vite";
import { unstable_reactRouterRSC } from "@react-router/dev/vite";

export default defineConfig({
  plugins: [
    unstable_reactRouterRSC(),
  ],
});

RSC Data Mode

Manual setup with runtime APIs:
// routes.ts
import type { unstable_RSCRouteConfigEntry } from "react-router";

export default [
  {
    id: "root",
    path: "/",
    Component: () => import("./routes/home"),
  },
] satisfies unstable_RSCRouteConfigEntry[];

Server Components

Components that render on the server:
// routes/dashboard.tsx
import { db } from "~/db.server"; // Server-only import

export default async function Dashboard() {
  // Direct database access - runs on server only
  const stats = await db.query("SELECT * FROM stats");
  
  return (
    <div>
      <h1>Dashboard</h1>
      <Stats data={stats} />
    </div>
  );
}

Client Components

Mark components for client rendering:
"use client";

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Mixing Server and Client

Compose server and client components:
// Server Component
import { ClientCounter } from "./Counter";
import { db } from "~/db.server";

export default async function Page() {
  const data = await db.getInitialCount();
  
  return (
    <div>
      <h1>Server-rendered content</h1>
      {/* Client component */}
      <ClientCounter initialCount={data.count} />
    </div>
  );
}

Server Actions

Call server functions from client:
"use server";

import { db } from "~/db.server";

export async function incrementCounter(id: string) {
  await db.query("UPDATE counters SET count = count + 1 WHERE id = ?", [id]);
  return { success: true };
}
Use in client components:
"use client";

import { incrementCounter } from "./actions";

export function Counter({ id }) {
  return (
    <form action={incrementCounter}>
      <input type="hidden" name="id" value={id} />
      <button type="submit">Increment</button>
    </form>
  );
}

Streaming

Stream components as they render:
import { Suspense } from "react";

export default function Page() {
  return (
    <div>
      <h1>Page Title</h1>
      
      {/* Streams when data is ready */}
      <Suspense fallback={<Skeleton />}>
        <AsyncData />
      </Suspense>
    </div>
  );
}

async function AsyncData() {
  const data = await fetchSlowData();
  return <div>{data}</div>;
}

Route Configuration

Define routes with RSC:
import type { unstable_RSCRouteConfigEntry } from "react-router";

export default [
  {
    id: "root",
    path: "/",
    async Component() {
      return <Layout />;
    },
    children: [
      {
        index: true,
        async Component() {
          const data = await getData();
          return <Home data={data} />;
        },
      },
    ],
  },
] satisfies unstable_RSCRouteConfigEntry[];

Loaders with RSC

Loaders still work alongside RSC:
export async function loader() {
  return { serverData: await getServerData() };
}

export default function Component() {
  const data = useLoaderData();
  return <div>{data.serverData}</div>;
}

Client Loaders

Augment server data on client:
export async function loader() {
  return { userId: await getUserId() };
}

export async function clientLoader({ serverLoader }) {
  const { userId } = await serverLoader();
  const preferences = localStorage.getItem("prefs");
  
  return {
    userId,
    preferences: JSON.parse(preferences),
  };
}

export default function Component() {
  const data = useLoaderData();
  return <div>User {data.userId}</div>;
}

Browser Entry

Set up RSC in the browser:
// entry.client.tsx
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import {
  createFromReadableStream,
  createTemporaryReferenceSet,
} from "@vitejs/plugin-rsc/browser";
import {
  unstable_getRSCStream as getRSCStream,
  unstable_RSCHydratedRouter as RSCHydratedRouter,
} from "react-router";

createFromReadableStream(
  getRSCStream(),
  { temporaryReferences: createTemporaryReferenceSet() }
).then((payload) =>
  startTransition(() => {
    hydrateRoot(
      document,
      <StrictMode>
        <RSCHydratedRouter
          createFromReadableStream={createFromReadableStream}
          payload={payload}
        />
      </StrictMode>
    );
  })
);

Server Entry

Handle RSC requests on the server:
// entry.server.tsx
import {
  createTemporaryReferenceSet,
  decodeAction,
  decodeFormState,
  decodeReply,
  loadServerAction,
  renderToReadableStream,
} from "@vitejs/plugin-rsc/rsc";
import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router";

export default async function handleRequest(request: Request) {
  return matchRSCServerRequest({
    createTemporaryReferenceSet,
    decodeAction,
    decodeFormState,
    decodeReply,
    loadServerAction,
    request,
    routes: routes(),
    generateResponse(match, { temporaryReferences, onError }) {
      return new Response(
        renderToReadableStream(match.payload, {
          temporaryReferences,
          onError,
        }),
        {
          status: match.statusCode,
          headers: match.headers,
        }
      );
    },
  });
}

Route Discovery

Routes are discovered lazily or eagerly:
<RSCHydratedRouter
  payload={payload}
  routeDiscovery="eager" // or "lazy"
  createFromReadableStream={createFromReadableStream}
/>
Eager: Discovers routes as links appear in DOM
Lazy: Discovers routes when clicked

Data Strategy

RSC uses a special data strategy for single-fetch:
import { getRSCSingleFetchDataStrategy } from "react-router/lib/rsc/browser";

const router = createRouter({
  routes,
  dataStrategy: getRSCSingleFetchDataStrategy(
    () => router,
    ssr,
    basename,
    createFromReadableStream,
    fetch
  ),
});

Error Boundaries

Handle errors in RSC:
export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
    </div>
  );
}

export default async function Component() {
  const data = await fetchData();
  if (!data) {
    throw new Error("No data found");
  }
  return <div>{data}</div>;
}

Hydration Fallback

Show loading state during hydration:
export function HydrateFallback() {
  return <div>Loading...</div>;
}

export async function clientLoader() {
  // Runs during hydration
  return await fetchClientData();
}

clientLoader.hydrate = true;

export default function Component() {
  const data = useLoaderData();
  return <div>{data}</div>;
}

Server-Only Code

Ensure code only runs on server:
// db.server.ts
import { db as database } from "better-sqlite3";

export const db = database("app.db");

if (typeof window !== "undefined") {
  throw new Error("Cannot import db on client");
}

Client-Only Code

Ensure code only runs on client:
// analytics.client.ts
export function trackEvent(event: string) {
  if (typeof window === "undefined") {
    throw new Error("Cannot call trackEvent on server");
  }
  
  window.gtag?.("event", event);
}

HMR with RSC

Hot Module Replacement works with RSC:
// Changes to server components update immediately
export default async function Page() {
  const data = await getData();
  return <div>{data}</div>;
}

// Edit component:
// <div>Updated: {data}</div>
// → Updates without full reload

Best Practices

  1. Use server components by default: Opt into client components
  2. Keep client components small: Minimize client bundle
  3. Use “use server” for actions: Clear server function boundary
  4. Don’t import server code in client: Use separate files
  5. Stream when possible: Use Suspense boundaries

Limitations

  • Experimental API, subject to change
  • Requires React 19+
  • Not all React features supported in server components
  • Cannot use hooks in server components
  • Client components cannot be async

Example App Structure

app/
  routes/
    _index.tsx           # Server component (default)
    dashboard.tsx        # Server component
    components/
      Counter.tsx        # "use client" - client component
      Chart.client.tsx   # Client-only (.client.tsx)
  actions/
    increment.server.ts  # "use server" - server action
  db.server.ts           # Server-only code
  entry.client.tsx       # Browser entry
  entry.server.tsx       # Server entry

Migration from Traditional SSR

  1. Mark interactive components with “use client”
  2. Move server logic to server components
  3. Convert form actions to server actions
  4. Update routing to RSC config
  5. Test both client and server rendering

Build docs developers (and LLMs) love