The Static Default Principle
Every page should be static until proven otherwise. A framework for making rendering decisions that minimize costs.
Key Insight
Every page should be static until proven otherwise. Most pages rendered server-side don't need to be.
Here's the uncomfortable truth: most pages rendered server-side don't need to be. We default to SSR because it's easier, not because it's necessary.
The cost difference is stark. A page that could be static but runs SSR might cost 100x more at scale.
The Rendering Cost Ladder
From cheapest to most expensive:
| Strategy | Cost per 100K views | When to Use |
|---|---|---|
| Static (SSG) | ~$0 | Content rarely changes |
| ISR (time-based) | ~$0.05 | Content changes periodically |
| ISR (on-demand) | ~$0.02 | Content changes unpredictably |
| SSR + cache headers | ~$2-5 | Personalized but cacheable |
| Pure SSR | ~$10-20 | Must be fresh every request |
The 100x Gap
Moving from pure SSR to ISR for a high-traffic page typically reduces costs by 95-99%. That's not optimization - that's a fundamentally different cost structure.
The Decision Tree
For every page, ask three questions:

Question 1: Does this content change per-request?
No → Static or ISR. Stop here.
Partly → Static shell + client-side fetch for dynamic parts.
Completely → Continue to Question 2.
Question 2: Is the content user-specific?
No → SSR with aggressive cache headers. One render serves many users.
Yes → Continue to Question 3.
Question 3: Does it need to be fresh on first paint?
No → Static shell + client fetch. User sees layout immediately, data loads after.
Yes → SSR is justified. But revisit this assumption regularly.
Implementation Patterns
Pattern 1: Full Static
The default for marketing, docs, blog posts:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
// No dynamic config = static by defaultCost: Build-time compute only. Zero runtime cost per view.
Pattern 2: ISR with Time-Based Revalidation
For content that updates but doesn't need instant freshness:
// app/pricing/page.tsx
export const revalidate = 3600; // Regenerate every hour
export default async function PricingPage() {
const plans = await getPlans();
return <PricingTable plans={plans} />;
}Cost: One function execution per hour, regardless of traffic.
Pattern 3: ISR with On-Demand Revalidation
For content that updates unpredictably (CMS changes, inventory updates):
// app/courses/[id]/page.tsx
export default async function CoursePage({ params }) {
const course = await getCourse(params.id);
return <CourseDetails course={course} />;
}
export const dynamic = 'force-static';
export async function generateStaticParams() {
const courses = await getAllCourses();
return courses.map((c) => ({ id: c.id }));
}Then trigger revalidation when content changes:
import { revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const { secret, courseId } = await request.json();
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidatePath(`/courses/${courseId}`);
return Response.json({ revalidated: true });
}Cost: One function execution per content change, not per view.
Pattern 4: Static Shell + Client Fetch
The best of both worlds for pages with personalized sections:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardShell } from './shell';
import { UserStats } from './user-stats';
import { RecentActivity } from './recent-activity';
export default function DashboardPage() {
return (
<DashboardShell>
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</DashboardShell>
);
}// app/dashboard/user-stats.tsx
'use client';
import useSWR from 'swr';
export function UserStats() {
const { data, isLoading } = useSWR('/api/user/stats');
if (isLoading) return <StatsSkeleton />;
return <StatsDisplay stats={data} />;
}Cost: Static shell is free. API calls are minimal (one per user session with SWR caching).
Pattern 5: SSR with Cache Headers
When you need server rendering but the result is shareable:
// app/trending/page.tsx
export const dynamic = 'force-dynamic';
export default async function TrendingPage() {
const trending = await getTrending();
return (
<>
<TrendingList items={trending} />
</>
);
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/trending') {
const response = NextResponse.next();
response.headers.set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=300'
);
return response;
}
}Cost: One render per minute serves all users. 60 requests/hour instead of potentially thousands.
Auditing Your Current State
Run next build and examine the output:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 89 kB
├ ○ /about 2.1 kB 86 kB
├ ● /blog/[slug] 3.4 kB 87 kB
├ ƒ /dashboard 8.1 kB 92 kB
├ ƒ /api/user 0 B 84 kB
└ ○ /pricing 2.8 kB 87 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demandFocus on the ƒ routes. For each one, ask: does this need to be dynamic?
Common Mistakes
Performance Killers
These common patterns silently convert static pages to SSR, dramatically increasing costs and latency.
Accidental Dynamic Routes
Adding async to a page component doesn't make it dynamic. But these do:
// These force dynamic rendering:
export const dynamic = 'force-dynamic';
cookies();
headers();
searchParams without generateStaticParams;
noStore();Audit your imports. Sometimes a utility deep in the tree calls cookies() and suddenly your whole page is SSR.
The Auth Trap
// This makes the entire page dynamic
export default async function Page() {
const session = await getServerSession(); // Calls cookies()!
return (
<main>
<PublicContent />
{session && <UserBadge user={session.user} />}
</main>
);
}Better approach:
// Static page with client-side auth check
export default function Page() {
return (
<main>
<PublicContent />
<ClientAuthBadge /> {/* Handles auth client-side */}
</main>
);
}Over-Using searchParams
// Dynamic because searchParams aren't known at build time
export default function SearchPage({ searchParams }) {
const query = searchParams.q;
return <SearchResults query={query} />;
}If the search itself happens client-side anyway, the page can be static:
// Static page, search happens client-side
export default function SearchPage() {
return <SearchInterface />; // Reads URL params client-side
}The Migration Playbook
When converting a high-traffic SSR page to static:
-
Identify dynamic dependencies - What data does it fetch? How often does it change?
-
Choose revalidation strategy - Time-based for predictable updates, on-demand for CMS/admin changes.
-
Handle edge cases - What if data is missing? What about new content not yet generated?
-
Set up cache invalidation - Webhook from CMS, admin action, or scheduled job.
-
Monitor after deploy - Check build output, verify pages are static, watch costs.
Results to Expect
| Migration | Typical Savings |
|---|---|
| Product pages SSR → ISR | 95-99% |
| Blog/docs SSR → SSG | 99%+ |
| Dashboard SSR → static shell | 60-80% |
| Listing pages SSR → ISR | 90-95% |
For the function-level optimizations that make SSR cheaper when you do need it, see Writing Cheaper Functions.