Implementing React Server Components for a web application
React Server Components (RSC) — this is not SSR in the traditional sense. This is a fundamentally new component model: server components execute only on the server, never hydrate on the client, add zero bytes to JS bundle. They can access databases directly, read filesystems, use server secrets — all without API layer.
RSC doesn't change the framework — RSC changes how you think about components.
Server Components vs Client Components
// SERVER component (default in App Router)
// This code NEVER reaches browser
import { db } from '@/lib/db'; // Direct Prisma/Drizzle import — normal
import { unstable_cache } from 'next/cache';
const getProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId, published: true },
include: { images: { take: 1 }, _count: { select: { reviews: true } } },
orderBy: { createdAt: 'desc' },
});
},
['products'],
{ revalidate: 300, tags: ['products'] }
);
export async function ProductList({ categoryId }: { categoryId: string }) {
const products = await getProducts(categoryId);
return (
<ul>
{products.map(product => (
<li key={product.id}>
{/* ProductCard — also server component */}
<ProductCard product={product} />
{/* AddToCartButton — client component */}
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
);
}
// CLIENT component — 'use client' is required
'use client';
import { useState, useTransition } from 'react';
import { addToCart } from '@/actions/cart'; // Server Action
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => addToCart(productId))}
disabled={isPending}
>
{isPending ? 'Adding...' : 'Add to cart'}
</button>
);
}
What Server Components can and cannot do
Server Components CAN:
-
async/awaitat top level of component - Direct database queries without API
- Read environment variables (including secrets)
- Import server-only libraries
- Render other server and client components
Server Components CANNOT:
- Use
useState,useEffect,useContext - Handle browser events (
onClick,onChange) - Use browser APIs (
localStorage,window) - Accept functions as props (not serializable)
Server Actions — mutations without API
// app/actions/products.ts
'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { z } from 'zod';
const UpdateProductSchema = z.object({
name: z.string().min(1).max(255),
price: z.number().positive(),
description: z.string().optional(),
});
export async function updateProduct(
productId: string,
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await auth();
if (!session?.user) return { error: 'Unauthorized' };
const parsed = UpdateProductSchema.safeParse({
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.product.update({
where: { id: productId },
data: parsed.data,
});
revalidateTag('products');
redirect(`/products/${productId}`);
}
// Use in form via useActionState
'use client';
import { useActionState } from 'react';
import { updateProduct } from '@/actions/products';
export function EditProductForm({ product }: { product: Product }) {
const [state, action, isPending] = useActionState(
updateProduct.bind(null, product.id),
null
);
return (
<form action={action}>
<input name="name" defaultValue={product.name} required />
{state?.error?.name && <p>{state.error.name[0]}</p>}
<input name="price" type="number" defaultValue={product.price} />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
Optimization: context and providers
Common mistake — wrapping entire app in Client Component with provider:
// Bad: entire layout becomes client
'use client';
export function Layout({ children }) {
return <ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>;
}
// Good: providers isolated, children are server
// providers.tsx
'use client';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider><QueryProvider>{children}</QueryProvider></ThemeProvider>;
}
// layout.tsx — server
import { Providers } from './providers';
export default async function RootLayout({ children }) {
const session = await auth(); // Server request in layout
return (
<html>
<body>
<Providers session={session}>{children}</Providers>
</body>
</html>
);
}
Pattern: passing server data through props
// Server component passes data to client
export default async function ProductPage({ params }: Props) {
const product = await getProduct(params.id);
const recommendations = await getRecommendations(product.categoryId);
return (
<div>
{/* Server components render static content */}
<ProductDetails product={product} />
<ProductImages images={product.images} />
{/* Client receives only needed data — not entire product */}
<ProductCarousel
items={recommendations.map(r => ({ id: r.id, name: r.name, image: r.images[0]?.url }))}
/>
</div>
);
}
Bundle size before and after RSC
Typical migration result on product pages:
| Component | Before RSC | After RSC |
|---|---|---|
| ProductList (data + render) | 24 KB JS | 0 KB JS |
| ProductDetails | 8 KB JS | 0 KB JS |
| AddToCartButton | 2 KB JS | 2 KB JS (client) |
| Total per page | 180 KB | 85 KB |
Server components add no JS — they add only HTML in response stream.
Implementation timeline
- Week 1–2: audit existing components, mark server/client boundaries, move data-fetching from API routes to server components
- Week 3: Server Actions for forms and mutations, replace REST calls with direct DB queries
-
Week 4: optimize providers (move to client without polluting layout), cache via
unstable_cache - Week 5: measure JS bundle before/after, tests (jest-environment for RSC), document boundaries
- Week 6: deployment, monitor server render, train team







