terms. Data changes only on deployment.
- Volatile/Real-time: User dashboards, live pricing, authenticated content. Data changes per request or session.
- Semi-static/Periodic: Product catalogs, blog posts, news feeds. Data changes hours or days, not per request.
Step 2: Implement SSG for Static Routes
Static generation should be the default for routes with deployment-bound data updates.
// app/about/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface AboutData {
title: string;
content: string;
updatedAt: string;
}
async function fetchAboutData(): Promise<AboutData> {
const res = await fetch('https://api.example.com/about', {
next: { revalidate: false }, // Explicitly disable ISR
});
if (!res.ok) notFound();
return res.json();
}
export async function generateMetadata(): Promise<Metadata> {
const data = await fetchAboutData();
return { title: data.title };
}
export default async function AboutPage() {
const data = await fetchAboutData();
return (
<article>
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
<time dateTime={data.updatedAt}>Last updated: {data.updatedAt}</time>
</article>
);
}
Step 3: Implement SSR for Real-time Routes
Server-side rendering should be reserved for authenticated or highly volatile data where staleness is unacceptable.
// app/dashboard/page.tsx
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
interface DashboardData {
userId: string;
metrics: Record<string, number>;
lastSync: string;
}
async function fetchDashboardData(): Promise<DashboardData> {
const cookieStore = await cookies();
const token = cookieStore.get('auth_token')?.value;
if (!token) redirect('/login');
const res = await fetch('https://api.example.com/dashboard', {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 0 }, // Force SSR
});
if (!res.ok) throw new Error('Dashboard fetch failed');
return res.json();
}
export const metadata: Metadata = { title: 'Dashboard' };
export default async function DashboardPage() {
const data = await fetchDashboardData();
return (
<section>
<h1>Welcome, {data.userId}</h1>
<pre>{JSON.stringify(data.metrics, null, 2)}</pre>
<p>Last synced: {new Date(data.lastSync).toLocaleString()}</p>
</section>
);
}
Step 4: Implement ISR for Semi-static Routes
Incremental Static Regeneration requires explicit revalidation windows, fallback handling, and cache-control discipline.
// app/products/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface ProductData {
slug: string;
name: string;
price: number;
stock: number;
description: string;
}
async function fetchProduct(slug: string): Promise<ProductData> {
const res = await fetch(`https://api.example.com/products/${slug}`, {
next: { revalidate: 300 }, // ISR: revalidate every 5 minutes
});
if (!res.ok) {
if (res.status === 404) notFound();
throw new Error('Product fetch failed');
}
return res.json();
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const product = await fetchProduct(params.slug);
return { title: product.name, description: product.description.slice(0, 150) };
}
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/products');
const products: ProductData[] = await res.json();
return products.map(p => ({ slug: p.slug }));
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await fetchProduct(params.slug);
return (
<article>
<h1>{product.name}</h1>
<p className="price">${product.price.toFixed(2)}</p>
<p className="stock">{product.stock > 0 ? 'In Stock' : 'Out of Stock'}</p>
<div className="description">{product.description}</div>
</article>
);
}
Step 5: Enforce Cache Boundaries & Fallback UX
ISR requires explicit handling of stale states and cache invalidation. Add cache-control headers and fallback components.
// app/products/[slug]/loading.tsx
export default function ProductLoading() {
return (
<article className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-24 bg-gray-200 rounded w-full" />
</article>
);
}
Architecture Decisions & Rationale
- Route-level granularity: Global rendering configuration forces trade-offs that don't apply to every page. Per-route strategy mapping aligns execution cost with data volatility.
- Explicit
revalidate values: Omitting revalidation defaults to framework assumptions. Explicit values prevent accidental SSG or unbounded SSR.
- TypeScript data contracts: Fetch functions return typed interfaces. This prevents runtime hydration mismatches and enforces compile-time validation of SSR/ISR payloads.
- Cache-control headers: ISR relies on edge caching. Misconfigured
Cache-Control or stale-while-revalidate directives cause either cache stampedes or stale delivery.
- Edge vs Node runtime: ISR and SSG benefit from edge deployment for TTFB. SSR requiring database connections or heavy computation should route to Node runtime to avoid cold-start penalties.
Pitfall Guide
1. Applying a Single Rendering Strategy Globally
Why it fails: Framework defaults optimize for developer experience, not route economics. Global SSR inflates compute costs; global SSG breaks dynamic content; global ISR without revalidation tuning causes cache thrashing.
Best practice: Audit routes by data lifecycle. Assign SSG, SSR, or ISR explicitly per route group.
2. Ignoring Revalidation Stampede Patterns
Why it fails: When revalidate expires simultaneously across thousands of routes, edge servers queue regeneration requests, spiking origin API load and increasing TTFB.
Best practice: Stagger revalidation windows using route patterns, implement jitter, and monitor origin API latency during peak regeneration cycles.
3. Misconfiguring ISR Fallback States
Why it fails: Missing loading.tsx or error.tsx boundaries cause hydration failures or blank screens during regeneration. Frameworks serve stale HTML until regeneration completes.
Best practice: Always provide fallback UI for ISR routes. Use skeleton loaders or cached placeholder states. Never assume regeneration is instantaneous.
4. Treating SSG as Zero-Cost
Why it fails: Static generation shifts cost to build pipelines and CDN invalidation. Large SSG sites with frequent deployments experience queue bottlenecks and egress spikes during cache purges.
Best practice: Separate build triggers from deployment. Use incremental builds, CDN purge APIs, and tag-based invalidation instead of full cache clears.
5. Mixing Client-Side Fetching with SSR/ISR Without Hydration Boundaries
Why it fails: Client-side useEffect or fetch inside server components causes hydration mismatches, double-fetching, and state drift.
Best practice: Keep data fetching in server components. Pass props to client components. Use useTransition or Suspense for interactive overlays, not primary data loading.
Why it fails: Search engines prioritize crawlability, structured data, and consistent cache headers. Performance metrics like LCP and INP matter for users, not crawlers.
Best practice: Align Cache-Control with SEO expectations. Use stale-while-revalidate for ISR, no-cache for SSR, and max-age for SSG. Validate with Google Search Console crawl stats.
7. Overlooking Edge Runtime Limitations
Why it fails: Edge environments lack Node.js APIs, file system access, and persistent connections. SSR routes requiring database queries or heavy cryptography fail or degrade at the edge.
Best practice: Route SSR to Node runtime when stateful operations are required. Keep ISR and SSG on edge for TTFB optimization. Use framework routing constraints to enforce this.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing/Legal pages updated per deployment | SSG | Immutable data, maximal CDN efficiency | Lowest (CDN only) |
| User dashboard with real-time metrics | SSR | Session-bound, requires fresh computation | Highest (compute + egress) |
| Product catalog with hourly price updates | ISR (300s) | Bounded staleness acceptable, high traffic | Moderate (CDN + edge compute) |
| Blog/news feed updated daily | ISR (86400s) | Low volatility, predictable traffic | Low-Moderate |
| Authenticated settings/profile page | SSR | User-specific, security-sensitive | High |
| Documentation with versioned releases | SSG | Deployment-triggered updates, static structure | Lowest |
| Live pricing/availability with sub-minute changes | SSR | Real-time accuracy required | Highest |
Configuration Template
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: ['@radix-ui/react-icons'],
},
// Route-level cache control via headers
async headers() {
return [
{
source: '/about/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
{
source: '/products/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=0, stale-while-revalidate=300' },
],
},
{
source: '/dashboard/:path*',
headers: [
{ key: 'Cache-Control', value: 'private, no-cache, no-store, must-revalidate' },
],
},
];
},
};
module.exports = nextConfig;
// middleware.ts (optional: enforce runtime routing)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');
const isProduct = request.nextUrl.pathname.startsWith('/products');
if (isDashboard) {
// Force Node runtime for stateful SSR
request.headers.set('x-middleware-cache', 'no-cache');
} else if (isProduct) {
// Allow edge caching for ISR
request.headers.set('x-middleware-cache', 'stale-while-revalidate');
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/products/:path*'],
};
Quick Start Guide
- Classify routes: Run
grep -r "fetch\|axios\|prisma" app/ to identify data-fetching routes. Tag each as static, semi-static, or real-time.
- Set revalidation defaults: Add
next: { revalidate: 0 } for SSR, revalidate: false for SSG, and explicit seconds for ISR in all fetch calls.
- Configure cache headers: Update
next.config.js headers to match strategy. Use max-age=31536000, immutable for SSG, stale-while-revalidate for ISR, no-cache for SSR.
- Add fallback boundaries: Create
loading.tsx and error.tsx in every ISR route directory. Test regeneration by triggering cache purge and observing TTFB.
- Deploy & monitor: Push to production. Track
TTFB, cache-hit-ratio, and origin-requests in your observability stack. Adjust revalidation windows if origin load spikes or staleness exceeds SLA.
Rendering strategy is not a framework setting. It is an architectural contract between data lifecycle, traffic patterns, and infrastructure economics. Map routes deliberately, enforce cache boundaries explicitly, and measure regeneration impact continuously. The performance and cost deltas are deterministic when the strategy matches the data.