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, 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.
| 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:
// 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.
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