A practical migration guide for moving Next.js applications from client-side data fetching to server components with caching.
Quick Start
- Find a page using
useEffectfor data fetching - Remove
"use client" - Make component
async - Wrap fetch in
unstable_cache - Test with DevTools (look for cache headers)
Details below, or jump to Real Example.
The Problem
Most Next.js apps default to client-side data fetching:
"use client";
export default function DashboardPage() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/dashboard') // Runs on every page visit - no caching!
.then(res => res.json())
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <LoadingSpinner />;
return <Dashboard data={data} />;
}
Issues: No caching, loading spinners everywhere, large JavaScript bundles, poor SEO.
The Solution
Server components with caching:
import { unstable_cache } from 'next/cache';
const getCachedDashboard = unstable_cache(
async () => {
const res = await fetch('/api/dashboard'); // Cached for 60s - subsequent visits are instant!
return res.json();
},
['dashboard'],
{ revalidate: 60 }
);
export default async function DashboardPage() {
const data = await getCachedDashboard();
return <Dashboard data={data} />;
}
Results: First load 226ms → cached loads 23-38ms (83% faster), no loading states, smaller bundles.
Note:
unstable_cacheAPI may change in future Next.js releases. Monitor release notes when upgrading.
Migration Strategy
When to Use Each Type
Component Decision Tree:
├─ Uses onClick/onChange? → Client Component
├─ Uses browser APIs? → Client Component
├─ Needs useState/useEffect? → Client Component
├─ Real-time updates? → Client Component
├─ Only displays data? → Server Component
└─ Fetches data? → Server Component + Cache
Migration Steps
- Remove
"use client"directive - Convert to async function
- Move data fetching into component
- Add caching with appropriate duration
- Extract interactive elements to client components
Real Example
// Before: Everything in one client component
"use client";
function ProductPage({ id }) {
const [product, setProduct] = useState(null);
const [quantity, setQuantity] = useState(1);
useEffect(() => {
fetchProduct(id).then(setProduct); // Fetches on every visit - no caching!
}, [id]);
return (
<div>
<h1>{product?.name}</h1>
<p>${product?.price}</p>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<button onClick={() => addToCart(id, quantity)}>
Add to Cart
</button>
</div>
);
}
// After: Server component with client island
// app/product/[id]/page.tsx
const getCachedProduct = unstable_cache(
(id) => fetchProduct(id), // Cached for 5 minutes - fast repeat visits!
(id) => ['product', id],
{ revalidate: 300 }
);
export default async function ProductPage({ params }) {
const product = await getCachedProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCartForm productId={params.id} /> {/* Only this part needs client */}
</div>
);
}
// components/add-to-cart-form.tsx
"use client";
export function AddToCartForm({ productId }) {
const [quantity, setQuantity] = useState(1);
return (
<>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<button onClick={() => addToCart(productId, quantity)}>
Add to Cart
</button>
</>
);
}
Common Patterns
Dashboard with Charts
export default async function Dashboard() {
const stats = await getCachedStats();
return (
<>
<StatsGrid data={stats} /> {/* Server: displays data */}
<InteractiveChart /> {/* Client: user can interact */}
</>
);
}
Form Pages
export default function ContactPage() {
return (
<>
<PageHeader /> {/* Server: static content */}
<ContactForm /> {/* Client: handles submission */}
<Footer /> {/* Server: static content */}
</>
);
}
Cache Invalidation
import { revalidateTag } from 'next/cache';
export async function updateData(formData) {
await saveToDatabase(formData);
revalidateTag('dashboard'); // Clear cache
}
Error Handling
export default async function Page() {
try {
const data = await getCachedData();
return <DataDisplay data={data} />;
} catch (error) {
return <ErrorMessage />; // No error boundaries needed
}
}
Cache Duration Guide
| Content Type | Cache Duration | Example Use Case |
|---|---|---|
| User dashboard | 30-60s | Recent activity |
| Product catalog | 5-60min | Inventory updates |
| Blog posts | Hours/days | Rarely changes |
| User-specific | 0-30s | Personal data |
Common Pitfalls
1. Functions Don't Serialize
// ❌ Functions can't pass from server to client
const config = {
validate: (val) => val > 0
};
// ✅ Pass data instead
const config = {
minValue: 0
};
Rule: If JSON.stringify() can't handle it, neither can server components.
2. Don't Over-Split
// ❌ Too many tiny components
<ServerWrapper>
<ServerTitle />
<ServerPrice />
<ClientButton />
</ServerWrapper>
// ✅ Logical grouping
<ProductCard> {/* Server: all static parts */}
<AddToCartButton /> {/* Client: just the button */}
</ProductCard>
Rule: Extract client components only for event handlers or hooks.
3. Forms Need Client
// ❌ Server components can't handle events
export default async function Form() {
return <form onSubmit={handleSubmit}>...
// ✅ Forms are always client components
"use client";
export default function Form() {
return <form onSubmit={handleSubmit}>...
Rule: If users can type or click, it's a client component.
Verification
Test your cache is working:
async function testCache(url) {
for (let i = 0; i < 3; i++) {
const start = Date.now();
const res = await fetch(url);
console.log(`Request ${i + 1}: ${Date.now() - start}ms - ${res.headers.get('x-nextjs-cache') || 'MISS'}`);
await new Promise(r => setTimeout(r, 1000));
}
}
Expected: First ~200ms (MISS), then ~30ms (HIT).
Production Tips
API Stability
- Pin Next.js version:
"next": "14.2.3"(no^) - Check release notes before upgrading
- Test in staging first
Edge Runtime
- Check database driver compatibility
- No file system access
- Some packages won't work
Performance Expectations
Results vary based on infrastructure and data source latency. Typical improvement: 50-90% for cached requests.
Summary
Use server components for:
- Data displays and dashboards
- Content that changes infrequently
- Reducing bundle size
Keep client components for:
- Forms and user input
- Real-time features
- Browser APIs
Start with one data-heavy page, measure the improvement, then expand.
Top comments (1)
Great breakdown of the mental model shift. I've been telling teams the same thing — default to server, only cross the boundary when you genuinely need interactivity. The JS bundle reduction alone (482KB to 89KB in our case) is hard to argue against.
One thing I'd add: the streaming aspect of RSC is often overlooked. You don't just send less JS, you send it progressively as the server finishes rendering each Suspense boundary.