How to Keep a Next.js Page Static While Using Dynamic Data

How to keep the static shell while a page still reads dynamic data, using Suspense boundaries, use cache, and runtime values passed as arguments.

8 min read

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.tsxApp.tsx
// 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.tsxApp.tsx
// 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.tsxApp.tsx
// 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.

MoveWhat stays static
Await params inside SuspenseEverything outside the boundary
Cache shared data with use cacheThe cached output
Stream per-request readsThe 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Does a static page have to avoid all request data?

No. With Cache Components, the static shell ships first and only the parts that read request data stream in behind Suspense boundaries.

What is the difference between static and cached data?

Static content is known at build time. Cached data is computed once and reused across requests, and it also joins the static shell when its lifetime is long enough.

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.