Back to Blog
Mar 18, 20264 min readOnuzulike Anthony

Static vs Dynamic Rendering in Next.js 15

When to use force-static, force-dynamic, or no directive at all — and how Next.js decides which strategy to use for App Router pages.

EngineeringNext.jsRenderingPerformanceEngineering

Next.js 15 gives you per-route control over rendering strategy. Getting this right means the difference between a page that loads in 50ms from a CDN and one that runs a database query on every visit.

The Three Strategies

Static (force-static): page is rendered at build time. Output is an HTML file. Zero server-side computation at request time.

Dynamic (force-dynamic): page is re-rendered on every request. Equivalent to SSR in older frameworks.

No directive: Next.js auto-detects. If the page reads cookies, search params, or uncached fetch(), it's dynamic. If it reads only static data or cached resources, it's static.

force-static

typescript
export const dynamic = "force-static";

Use for:

  • Blog posts and documentation (content doesn't change per-user)
  • Marketing pages
  • Any page where all users see identical HTML

With Cloudflare Workers via OpenNext, force-static pages are served as static assets — they never hit your Worker code. This is as fast as it gets.

typescript
// Blog post page — all content known at build time
export const dynamic = "force-static";
 
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map(post => ({ slug: post.slug }));
}
 
export default async function PostPage({ params }) {
  const post = getPost(params.slug); // reads filesystem, not network
  if (!post) notFound();
  return <Article post={post} />;
}

force-dynamic

typescript
export const dynamic = "force-dynamic";

Use for:

  • User-specific pages (dashboard, watch history, profile)
  • Pages that read auth cookies
  • Real-time data that can't be stale
typescript
// User watch history — different per user, can't be prebuilt
export const dynamic = "force-dynamic";
 
export default async function HistoryPage() {
  const session = await getServerSession();
  if (!session) redirect("/login");
 
  const history = await getUserHistory(session.userId);
  return <WatchHistory items={history} />;
}

Auto-Detection (No Directive)

When you omit the directive, Next.js audits the page for dynamic signals:

  • cookies() — dynamic
  • headers() — dynamic
  • searchParams — dynamic (in Next.js 15, this is now async)
  • fetch() without cache — dynamic
typescript
// This page is auto-detected as dynamic because it reads searchParams
export default async function SearchPage({
  searchParams
}: {
  searchParams: Promise<{ q: string }> // async in Next.js 15
}) {
  const { q } = await searchParams;
  const results = await searchContent(q);
  return <SearchResults results={results} />;
}

Caching fetch() to Stay Static

If a page needs external data but can still be static, cache the fetch:

typescript
// Fetch with Next.js cache — refreshes every hour
const response = await fetch("https://api.themoviedb.org/3/trending/movie/week", {
  next: { revalidate: 3600 },
  headers: { Authorization: `Bearer ${process.env.TMDB_TOKEN}` },
});

This page will be treated as static with incremental revalidation (ISR). The first request after the cache expires triggers a background re-render; subsequent requests still get the cached version instantly.

Partial Pre-rendering (PPR)

Next.js 15's experimental PPR renders the static shell at build time and streams dynamic parts on request:

typescript
import { Suspense } from "react";
 
export default function MoviePage({ params }) {
  return (
    <div>
      {/* Static shell — pre-rendered */}
      <MovieHeader id={params.id} />
 
      {/* Dynamic parts — streamed per-request */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <PersonalizedRecommendations />
      </Suspense>
    </div>
  );
}

In next.config.ts:

typescript
const config: NextConfig = {
  experimental: {
    ppr: true,
  },
};

The Decision Rule

  1. Does the page show the same content to every user? → force-static
  2. Does it need cookies, auth, or user-specific data? → force-dynamic
  3. Does it need fresh external data but can tolerate some staleness? → No directive + fetch() with revalidate
  4. Mix of static shell and dynamic content? → PPR with <Suspense>

The most common mistake is leaving pages dynamic when they could be static — typically because they import a component that somewhere calls cookies() or headers(). Use next build --debug to see why Next.js chose dynamic for each route.