The Static Default Principle
Every page should be static until proven otherwise. This mindset shift alone can cut costs by 90%.
The Rule
Static until proven otherwise. Use ISR when content updates. Use SSR only when content must be fresh, personalized, AND in the first paint. Most "dynamic" pages fail at least one of these tests.
Here's the uncomfortable truth: most pages that run server-side don't need to.
We default to SSR because it's convenient, not because it's necessary. The page works, so we ship it. But at scale, that convenience costs 100x what it should.
The Cost Gap Is Massive
Let's put real numbers on it:
| Strategy | Cost per 100K views | When You'd Use It |
|---|---|---|
| Static (SSG) | ~$0 | Content that rarely changes |
| ISR (time-based) | ~$0.05 | Updates every few minutes/hours |
| ISR (on-demand) | ~$0.02 | Updates when you trigger it |
| SSR + cache headers | ~$2-5 | Dynamic but shareable across users |
| Pure SSR | ~$10-20 | Must be fresh AND personalized |
Moving from pure SSR to ISR typically cuts costs by 95-99%. That's not optimization—that's a different cost structure entirely.
The Math
A product page with 100K monthly views costs ~0.05. Same data freshness for practical purposes, 300x cheaper.
The Three-Question Test
Before you render a page server-side, ask:

Question 1: Does the content actually change per-request?
No → Stop. Use Static or ISR. You're done.
Partly → Static shell + client-side fetch for the dynamic bits.
Completely → Continue to Question 2.
Question 2: Is it personalized to the user?
No → Use SSR with cache headers. One render serves thousands of users.
Yes → Continue to Question 3.
Question 3: Does it NEED to be in the first paint?
No → Static shell + client-side fetch. Users see the layout instantly, data loads after.
Yes, absolutely → Fine, use SSR. But revisit this assumption in a few months.
Most pages don't pass all three tests. Most pages can be static or ISR.
The Patterns
Pattern 1: Pure Static
For marketing pages, docs, blog posts—anything that doesn't change between requests:
// 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: Zero. You pay at build time, nothing at runtime.
Pattern 2: ISR with a Timer
For content that updates but doesn't need to be real-time:
// app/pricing/page.tsx
export const revalidate = 3600;
export default async function PricingPage() {
const plans = await getPlans();
return <PricingTable plans={plans} />;
}Cost: One function execution per hour. Doesn't matter if you get 100 visits or 100,000—same price.
Pattern 3: ISR with On-Demand Revalidation
For content that changes unpredictably (CMS publishes, inventory updates):
// app/products/[id]/page.tsx
export const dynamic = 'force-static';
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map((p) => ({ id: p.id }));
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <ProductDetails product={product} />;
}Then when the product updates:
// app/api/webhooks/cms/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const { secret, productId } = await request.json();
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Nope' }, { status: 401 });
}
revalidatePath(`/products/${productId}`);
return Response.json({ done: true });
}Cost: One function execution per content change. Not per view.
Pattern 4: Static Shell + Client Data
The best of both worlds for dashboards and personalized pages:
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<DashboardShell>
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* Fetches client-side */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* Fetches client-side */}
</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: Shell is free (static). API calls are minimal—SWR caches locally.
Pattern 5: SSR with Cache Headers (Last Resort)
When you genuinely need server rendering but the result can be shared:
// 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 everyone. Instead of thousands of renders, you get 60/hour.
How to Audit Your Current State
Run next build and look at 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
ƒ (Dynamic) server-rendered on demandEvery ƒ is a question mark. Does it need to be dynamic?
The Usual Mistakes
Hidden Dynamic Triggers
These silently force your page into SSR:
// Any of these = SSR
cookies();
headers();
searchParams;
noStore();The tricky part: these might be deep in your import tree. A utility calls cookies() somewhere, and suddenly your whole page is dynamic.
Fix: Audit your imports. Search for these function calls across your codebase.
The Auth Trap
// This makes the ENTIRE page dynamic
export default async function Page() {
const session = await getServerSession(); // Calls cookies()!
return (
<main>
<PublicHero />
<PublicFeatures />
{session && <UserBadge user={session.user} />}
</main>
);
}You're making 99% of the page dynamic for a tiny user badge. Better:
// Static page with client-side auth
export default function Page() {
return (
<main>
<PublicHero />
<PublicFeatures />
<ClientUserBadge /> {/* Checks auth client-side */}
</main>
);
}Over-Using searchParams
// Dynamic because searchParams isn't known at build time
export default function SearchPage({ searchParams }) {
const query = searchParams.q;
return <SearchResults query={query} />;
}But if search happens client-side anyway:
// Static shell, search is client-side
export default function SearchPage() {
return <SearchInterface />; // Reads URL params in the browser
}The Savings
When you convert high-traffic SSR pages:
| Migration | Typical Savings |
|---|---|
| Product pages → ISR | 95-99% |
| Blog/docs → SSG | 99%+ |
| Dashboard → static shell | 60-80% |
| Listing pages → ISR | 90-95% |
The Summary
Run next build and find the ƒ routes
Each one is either necessary or a cost leak. Question them all.
Default to ISR
Time-based or on-demand. You get fresh content without per-request costs.
Use static shells for personalized pages
Serve the layout instantly, fetch user data client-side. Feels fast, costs little.
Hunt for hidden dynamic triggers
cookies(), headers(), searchParams—audit your imports for these.
When you do need SSR, make it cheaper. See Writing Cheaper Functions for the code-level optimizations.