To keep a Next.js page static while it reads dynamic data, build a static shell that ships instantly and stream only the parts that depend on the request. The shell is the static part and the stream is the dynamic part, so the goal is to keep the shell as large as possible. This article assumes the Cache Components model, where caching is opt-in and data is dynamic by default.
Push dynamic work down the tree
The deeper your async work sits, the more of the page can prerender. A page that awaits params at the top level cannot prerender, but one that awaits them inside a Suspense boundary can.
// app/shop/[slug]/page.tsx
import { Suspense } from 'react'
type Params = Promise<{ slug: string }>
async function Product({ params }: { params: Params }) {
const { slug } = await params
const res = await fetch(`https://api.example.com/products/${slug}`)
const product = await res.json()
return <h1>{product.name}</h1>
}
export default function ProductPage({ params }: { params: Params }) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Product params={params} />
</Suspense>
)
}The page frame and the fallback prerender into the shell. Only the product name streams in, and everything outside this boundary stays static.
Move that await up to the page function and the outcome is not a slower page, it is a failed build. Cache Components treats an unresolved param read outside a boundary as a prerender-blocking error rather than quietly falling back to dynamic rendering.
Cache what is shared
Shared data that is the same for every visitor can join the shell with use cache.
// app/lib/posts.ts
import { cacheLife } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours')
const res = await fetch('https://api.example.com/posts')
return res.json()
}Because this function reads no request data, its result is baked into the static shell for every visitor and refreshed hourly. A short lifetime, such as an expire under five minutes, keeps the result out of the shell instead.
Per-user data needs a different tool. A private cache may read cookies and headers directly, but its result is never stored on the server: it lives in browser memory only and does not survive a reload.
Stream what is per-request
Data that depends on the visitor stays out of the shell and streams behind a fallback. Read cookies and search params inside a Suspense boundary, and pass the values into any cached function as arguments rather than reading them inside the cache.
// app/feed/page.tsx
import { cookies } from 'next/headers'
import { Suspense } from 'react'
async function getHeadlines(region: string) {
'use cache'
const res = await fetch(`https://api.example.com/headlines?region=${region}`)
return res.json()
}
async function Feed() {
const region = (await cookies()).get('region')?.value ?? 'us'
const headlines: string[] = await getHeadlines(region)
return <ul>{headlines.map((h) => <li key={h}>{h}</li>)}</ul>
}
export default function FeedPage() {
return (
<Suspense fallback={<p>Loading headlines...</p>}>
<Feed />
</Suspense>
)
}The cookie read happens in the uncached component, and only the resulting value crosses into the cached function. That value becomes part of the cache key, so every visitor in the same region shares one cached result instead of each getting a private copy.
Reading the cookie inside the cached scope instead would be rejected, because a plain use cache scope cannot touch request APIs. The visitor sees the fallback first, then the headlines, while the rest of the page was already there from the shell.
| Move | What stays static |
|---|---|
| Await params inside Suspense | Everything outside the boundary |
| Cache shared data with use cache | The cached output |
| Stream per-request reads | The fallback UI |
The boundary is the line. Anything that must differ per visitor belongs behind it, and everything shared belongs in the shell.
For the APIs that force the stream, see the dynamic APIs article. To turn the feature on, see enabling cacheComponents.
The pre-Cache-Components model
Without Cache Components, the same goal uses generateStaticParams plus the revalidate or dynamic segment configs. Setting dynamic = 'error' there fails the build if anything inside tries to turn dynamic, which is the closest that model comes to guaranteeing a static page.
The important difference is granularity. In the old model the whole route is static or dynamic, so keeping a page static means keeping every request-time read out of it, and freshness comes from a route-level timer rather than a per-boundary decision.
Those options are removed when cacheComponents is on, so pick one model and stay with it. Mixing them is the usual reason an example copied from an older tutorial neither prerenders nor errors the way its author expected.
Rune AI
Key Insights
- Await params and searchParams inside Suspense, not at the page top.
- Cache shared data with use cache so it joins the static shell.
- Stream per-request reads behind Suspense fallbacks.
- Read runtime values outside cached scopes and pass them as arguments.
- The pre-Cache-Components model uses route segment config instead.
Frequently Asked Questions
Does a static page have to avoid all request data?
What is the difference between static and cached data?
Conclusion
A static page and dynamic data are not opposites. Keep the shell static by pushing async work into Suspense boundaries, cache what is shared, and stream only what is per-request.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.