Skip to content

React Server Components with TypeScript — Production Guide

React Server Components run on the server with zero bundle cost. Here's how to type them in TypeScript and use them in production with Next.js.

· · 6 min read
Server infrastructure code and React component tree shown in a code editor

Quick Take

React Server Components render on the server with zero client bundle cost but can't use state, effects, or browser APIs. This guide covers the practical TypeScript patterns for RSCs in Next.js 15, including how to type the client/server boundary correctly.

Quick take: - React Server Components (RSC) run exclusively on the server. They have zero bundle cost, can fetch data with async/await directly in the component, and never ship JavaScript to the browser. The TypeScript setup is straightforward: declare the component async, type props normally, and use Promise<T> for async data. The main rule: Server Components can't use hooks or browser APIs, production is covered here.

Server Components shipped in React 18 but became practical through Next.js App Router (Next.js 13.4+). If you're building with Next.js 14 or 15, you're already using them, every component in the app directory is a Server Component by default unless you add 'use client' at the top.

What Exactly Are React Server Components?

A Server Component runs on the server during rendering. It never ships to the client. This means:

  • No useState, useEffect, or any other hook
  • No browser APIs (window, document, localStorage)
  • Direct database access, file system reads, or any Node.js API
  • Zero bundle size contribution, the output is serialized React elements, not JavaScript

The trade-off is real. You gain performance and data access. You lose interactivity. The practical pattern: make the outer shell a Server Component that fetches data, then pass that data as props to Client Components that handle user interaction.

FeatureServer ComponentClient Component
Runs onServer onlyBrowser (+ server in SSR)
Hooks allowedNoYes
Browser APIsNoYes
Bundle size0 KBIncluded in JS bundle
Data fetchingDirect (DB, FS, API)Via fetch/SWR/React Query
InteractivityNoYes
Directive neededNone (default)"use client" at top

How Do You Type Server Components in TypeScript?

Typing Server Components is mostly the same as regular components. The main difference is async:

// app/users/page.tsx, Server Component (no 'use client')

interface User {
  id: string;
  name: string;
  email: string;
}

interface UsersPageProps {
  searchParams: Promise<{ page?: string }>;
}

export default async function UsersPage({ searchParams }: UsersPageProps) {
  const { page = '1' } = await searchParams;
  const users = await fetchUsers(parseInt(page, 10));

  return (
    <main>
      <h1>Users</h1>
      <UserList users={users} />
    </main>
  );
}

async function fetchUsers(page: number): Promise<User[]> {
  const res = await fetch(`/api/users?page=${page}`, { cache: 'force-cache' });
  if (!res.ok) throw new Error(`Failed to fetch users: ${res.status}`);
  return res.json() as Promise<User[]>;
}

Note: In Next.js 15, searchParams and params are Promise<T> types. You must await them. This changed from Next.js 14 where they were synchronous objects. If you're upgrading, this is the most common TypeScript error you'll hit, I spent a solid two hours tracking down a type mismatch on searchParams the first time I migrated a project from 14 to 15.

How Do You Mix Server and Client Components?

This is where most developers get confused. The rule: Server Components render Client Components, not the other way around.

// app/dashboard/page.tsx, Server Component
import { DashboardChart } from './DashboardChart'; // Client Component
import { getMetrics } from '@/lib/db';

export default async function DashboardPage() {
  const metrics = await getMetrics(); // Direct DB call, fine in server component

  // Pass serializable data to client component
  return <DashboardChart data={metrics} />;
}
// app/dashboard/DashboardChart.tsx, Client Component
'use client';

import { useState } from 'react';

interface DashboardChartProps {
  data: Metric[];
}

export function DashboardChart({ data }: DashboardChartProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  // Can use hooks, it's a client component
  return (
    <div>
      {data.map((m, i) => (
        <div key={m.id} onClick={() => setActiveIndex(i)}>
          {m.label}: {m.value}
        </div>
      ))}
    </div>
  );
}

The props you pass from a Server to Client Component must be serializable. Functions, class instances, and non-JSON values don't serialize. Pass plain objects, arrays, strings, and numbers.

How Do You Handle Loading and Error States?

Next.js App Router uses convention-based files for this:

// app/users/loading.tsx, shown while Server Component fetches
export default function Loading() {
  return <div className="skeleton" aria-busy="true">Loading users...</div>;
}

// app/users/error.tsx, shown if Server Component throws
'use client'; // error boundaries must be client components

interface ErrorProps {
  error: Error;
  reset: () => void;
}

export default function Error({ error, reset }: ErrorProps) {
  return (
    <div>
      <p>Failed to load: {error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  );
}

The loading.tsx file wraps your page in a Suspense boundary automatically. The error.tsx file creates an error boundary. Both are typed for you, just match the interface.

What About Caching?

This is the part that trips people up most, it tripped me up too. Server Components use the fetch API's cache option:

// Never cached, always fresh data
const live = await fetch('/api/feed', { cache: 'no-store' });

// Cached indefinitely until revalidated
const static_data = await fetch('/api/config', { cache: 'force-cache' });

// Cached for 60 seconds, then revalidated in background
const news = await fetch('/api/headlines', { next: { revalidate: 60 } });

The TypeScript types for these options are built into Next.js. Pass them to the RequestInit type's next extension field. In practice, most data fetching calls want either no-store (always fresh) or a specific revalidate interval, force-cache is really for data that almost never changes.

Server Actions: Mutations From Client Components

Server Actions are async functions that run on the server but can be called from Client Components. They're the RSC answer to API routes for form submissions and mutations:

// app/actions/createUser.ts
'use server';

import { z } from 'zod';

const schema = z.object({ name: z.string().min(2), email: z.string().email() });

export async function createUser(formData: FormData) {
  const result = schema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

  await db.users.create(result.data);
  return { success: true };
}
// Client Component calling the server action
'use client';

import { createUser } from '@/app/actions/createUser';
import { useActionState } from 'react';

export function CreateUserForm() {
  const [state, action, isPending] = useActionState(createUser, null);

  return (
    <form action={action}>
      <input name="name" />
      <input name="email" type="email" />
      {state?.error && <p>{JSON.stringify(state.error)}</p>}
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

The 'use server' directive marks the file (or individual function) as a Server Action. TypeScript types flow through from the action's return type to useActionState's state type automatically.

Common TypeScript Errors and How to Fix Them

"Type 'Promise<User[]>' is not assignable to type 'User[]'", you forgot to await the async function.

"Property 'searchParams' implicitly has type 'any'", add the Next.js types: import type { PageProps } from './$types' or define the interface manually.

"Functions cannot be passed directly to Client Components", you're trying to pass a Server-side function as a prop. Convert it to a Server Action with 'use server', or move the function into the Client Component.

"Cannot find module 'server-only'", install it with npm install server-only (the server-only package) and import it at the top of files that should never run client-side.

Frequently Asked Questions

Do React Server Components replace getServerSideProps?
In Next.js App Router, yes. Server Components fetch data directly via async/await in the component body. getServerSideProps is a Pages Router concept, it does not exist in the App Router.
Can Server Components use React hooks?
No. useState, useEffect, useCallback, and other hooks run on the client and cannot be used in Server Components. If you need interactivity, make that subtree a Client Component with 'use client'.
What is the difference between a Server Component and a Client Component?
Server Components run on the server only, they have no JavaScript bundle cost and can access databases directly. Client Components run in the browser and support interactivity. Server Components can render Client Components but not the reverse.
How do I type async Server Components in TypeScript?
Declare the component as async and type its props normally. TypeScript 5+ and React 18+ types handle async component types. In Next.js App Router, page.tsx and layout.tsx components are async by default.