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
useEffect
for 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_cache
API 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 (0)