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.
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
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.
// 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
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
// 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()— dynamicheaders()— dynamicsearchParams— dynamic (in Next.js 15, this is now async)fetch()without cache — dynamic
// 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:
// 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:
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:
const config: NextConfig = {
experimental: {
ppr: true,
},
};The Decision Rule
- Does the page show the same content to every user? →
force-static - Does it need cookies, auth, or user-specific data? →
force-dynamic - Does it need fresh external data but can tolerate some staleness? → No directive +
fetch()withrevalidate - 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.