perf hacks
RenderingStrategies

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:

StrategyCost per 100K viewsWhen to Use
Static (SSG)~$0Content rarely changes
ISR (time-based)~$0.05Content changes periodically
ISR (on-demand)~$0.02Content changes unpredictably
SSR + cache headers~$2-5Personalized but cacheable
Pure SSR~$10-20Must 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:

Rendering strategy decision tree showing the three questions: Does content change per-request? Is it personalized? Must it be fresh on first paint?

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 default

Cost: 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:

app/api/cms-webhook/route.ts
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 demand

Focus 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:

  1. Identify dynamic dependencies - What data does it fetch? How often does it change?

  2. Choose revalidation strategy - Time-based for predictable updates, on-demand for CMS/admin changes.

  3. Handle edge cases - What if data is missing? What about new content not yet generated?

  4. Set up cache invalidation - Webhook from CMS, admin action, or scheduled job.

  5. Monitor after deploy - Check build output, verify pages are static, watch costs.

Results to Expect

MigrationTypical Savings
Product pages SSR → ISR95-99%
Blog/docs SSR → SSG99%+
Dashboard SSR → static shell60-80%
Listing pages SSR → ISR90-95%

For the function-level optimizations that make SSR cheaper when you do need it, see Writing Cheaper Functions.

On this page