Quick take: React Server Components (RSC) run exclusively on the server. They have zero bundle cost, can fetch data with
async/awaitdirectly in the component, and never ship JavaScript to the browser. The TypeScript setup is straightforward: declare the componentasync, type props normally, and usePromise<T>for async data. The main rule: Server Components can't use hooks or browser APIs.
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 15 or 16, 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.
Next.js 16 pushed RSC further this year. It ships with Cache Components, a programming model built on Partial Pre-Rendering that lets a route serve a static shell instantly while Server Components stream in the dynamic parts behind a use cache boundary. React itself is at 19.2.x as of July 2026 (no React 20 on the roadmap yet), and the 19.2 line added Web Streams support for server rendering in Node, plus decoding performance work that showed up in the 19.2.8 patch. None of this changes the TypeScript patterns below, but it does mean no-store and revalidate aren't your only caching levers anymore, use cache is worth learning if you're on Next.js 16.
What Exactly Are React Server Components?
React Server Components are a component type introduced in React 18 that renders exclusively on the server and never ships its own JavaScript to the browser, unlike traditional client-rendered components that ship both markup and the code that produced it. 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. On a dashboard I converted last spring, moving the data-fetching shell to a Server Component and leaving only the chart interactive cut the route's client bundle from 214 KB to 89 KB, because the date library and the ORM types stopped crossing the boundary entirely. The React documentation on Server Components frames this as moving work "back to the server", which undersells it slightly, the bundle savings come from code that never gets sent at all rather than code that runs somewhere else.
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on | Server only | Browser (+ server in SSR) |
| Hooks allowed | No | Yes |
| Browser APIs | No | Yes |
| Bundle size | 0 KB | Included in JS bundle |
| Data fetching | Direct (DB, FS, API) | Via fetch/SWR/React Query |
| Interactivity | No | Yes |
| Directive needed | None (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. There's no special ServerComponent<Props> type to reach for and no generic wrapper to import, you declare the function async, annotate its props with a plain interface, and let the return type infer. React's own types have handled async function components since 18.3, so on any React 19.x line this compiles without a single @ts-expect-error. The one place people reach for a type import unnecessarily is the page props object, which Next.js generates for you per route in .next/types once you've run a build:
// 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, and a type mismatch on searchParams can eat a solid two hours the first time you migrate a project from 14 to 15.
How Do You Mix Server and Client Components?
Composition is where most developers get confused. The rule: Server Components render Client Components, not the other way around. A Client Component can receive server-rendered markup through children, but it can't import a Server Component and render it directly, because by the time the client module graph is built that server code is gone. Think of the boundary as one-directional. Once a component carries 'use client', everything it imports gets pulled into the browser bundle too, which is how a single misplaced directive at the top of a shared layout can drag an entire component tree client-side without any error to warn you.
// 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. Dates are the one that catches people, a Date survives the boundary in Next.js 15 and later but a Prisma model instance with methods on it does not, so strip your ORM rows down to plain objects before they cross.
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. According to the Next.js documentation on error handling, error.tsx must be a Client Component because error boundaries rely on React's componentDidCatch lifecycle, which only exists on the client. In practice this means a Server Component that throws during rendering gets caught by the nearest client-rendered error.tsx boundary, not by anything running server-side, so the reset function you wire up re-triggers the original server render rather than just re-rendering client state.
What About Caching?
Caching is the part that trips people up most, and it tripped me up too. Server Components use the fetch API's cache option, which Next.js extends with a next field the standard RequestInit type doesn't have. Three values cover almost every case in production, and picking the wrong one is the difference between a page that serves from cache in 12 ms and one that hits your database on every request:
// 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. One caveat worth knowing before you lean on any of it: the default changed in Next.js 15, where an uncached fetch no longer caches automatically the way it did in 14. Plenty of upgrades have shipped with a quiet performance regression for exactly that reason.
What Are Server Actions and How Do You Use Them?
Server Actions are async functions marked with the 'use server' directive that run exclusively on the server but can be invoked directly from a Client Component, replacing the separate API route you'd otherwise write for a form submission. 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, so the state?.error access above is checked against the Zod flatten() shape without a single manual annotation. Two constraints matter in production. Every exported function in a 'use server' file becomes a public HTTP endpoint, so validate the input on the server even when the client already did, and the arguments have to be serializable in the same way Client Component props are. According to the React documentation on Server Actions, the framework generates a stable action ID per function, which is why renaming an exported action invalidates any form that was already rendered against the old build.
What Are Common TypeScript Errors and How Do You Fix Them?
Four errors account for nearly everything you'll hit on a real migration, and three of them come from the same root cause: something crossed the server-to-client boundary that shouldn't have. Before digging into individual messages, here's the triage order that saves the most time:
- Check whether the value is a
Promise<T>that still needsawait, this covers most Next.js 15searchParamsandparamserrors - Confirm the failing prop is serializable, functions and class instances cannot cross the server-to-client boundary
- Verify the file or function has the correct
'use client'or'use server'directive at the top - Re-run
tsc --noEmitafter each fix rather than batching several guesses together
"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.
Related
- React 19 New Features,
useActionStateand Actions pair directly with Server Actions - TypeScript Generics Guide, type your data-fetching helpers and Server Action return types with generics
- React Hooks Best Practices, hooks still apply in Client Components; understand when you're in one
- TanStack Query, consider TanStack Query for complex client-side caching alongside Server Components