How to Fix: [PPR][DIO] Dynamic Segment Static Shell is blank
[PPR][DIO] Dynamic Segment Static Shell is blank in Next.js: why it happens and how to fix it
A blank page with JavaScript disabled usually means your static shell never rendered on the server. In this issue, a route using dynamic segments together with Partial Prerendering (PPR) and Dynamic IO falls back to an empty shell because the framework cannot safely produce static HTML for that segment at build time.
Table of Contents
You can review the reproduction from the linked GitHub repository.
Understanding the Root Cause
The bug appears when a page under a dynamic route such as app/[slug]/page.tsx is expected to output a static shell, but some part of the rendering path depends on runtime-only information. With PPR, Next.js tries to split the page into two pieces:
- a static HTML shell that can be served immediately
- dynamic content that can stream later or hydrate on the client
That model breaks down if the route itself cannot be statically resolved. A dynamic segment is not automatically static. For the shell to exist during build or prerender time, Next.js must know which params are valid, or it must be allowed to render a deterministic fallback.
In practice, the blank shell usually comes from one or more of these technical causes:
- The page reads request-time data too early, such as cookies, headers, or uncached fetches.
- The route param is dynamic, but generateStaticParams() is missing, incomplete, or incompatible with the page behavior.
- A component in the static tree is marked dynamic by Dynamic IO, forcing the framework to defer more than expected.
- The fallback content is inside the wrong boundary, so the server sends almost no meaningful HTML when JavaScript is off.
- The route is effectively treated as dynamic = force-dynamic, which disables the static prerendered shell for that segment.
When JavaScript is disabled, the browser only shows what the server sent as initial HTML. If the server emitted an empty wrapper plus client-side logic that never runs, the result is the blank page seen in the issue.
The key principle is simple: a PPR shell must be renderable without request-only dependencies. If your dynamic segment needs static HTML, then the route params, shell content, and data used by that shell must all be statically known or cacheable.
Step-by-Step Solution
The fix is to make the route’s shell truly static, then isolate dynamic behavior behind boundaries that do not block server HTML.
1. Define static params for the dynamic segment
If the route is something like app/[slug]/page.tsx, provide generateStaticParams() so Next.js can prerender actual paths.
export async function generateStaticParams() {
return [
{ slug: 'post-1' },
{ slug: 'post-2' },
{ slug: 'post-3' },
]
}
This is the most important step when the issue is tied to a dynamic segment static shell. Without known params, the shell often cannot be emitted as expected.
2. Keep the shell free of request-time APIs
Do not call APIs such as cookies(), headers(), or uncached fetches in the shell portion of the page.
import { Suspense } from 'react'
export default async function Page({ params }: { params: { slug: string } }) {
return (
<main>
<h1>Article: {params.slug}</h1>
<p>This content is part of the static shell.</p>
<Suspense fallback={<p>Loading dynamic details...</p>}>
{/* Dynamic data should be isolated here */}
{/* <DynamicSection slug={params.slug} /> */}
</Suspense>
</main>
)
}
The idea is to ensure the outer HTML can be produced at build time or from cached server output.
3. Cache data used by the shell
If the shell needs fetched data, make it cacheable so it participates in static generation.
async function getStaticArticle(slug: string) {
const res = await fetch(`https://example.com/api/articles/${slug}`, {
next: { revalidate: 3600 },
})
if (!res.ok) {
throw new Error('Failed to fetch article')
}
return res.json()
}
Avoid patterns that implicitly mark the route dynamic:
async function getDynamicArticle(slug: string) {
const res = await fetch(`https://example.com/api/articles/${slug}`, {
cache: 'no-store',
})
return res.json()
}
cache: ‘no-store’ is a common reason the shell disappears.
4. Do not force the entire page dynamic unless you actually need it
Check for route-level configuration that disables static output.
export const dynamic = 'force-static'
Or, if you need ISR-style behavior:
export const revalidate = 3600
If your code currently contains this, it may explain the blank shell:
export const dynamic = 'force-dynamic'
That setting tells Next.js to render at request time, which works against a static shell strategy.
5. Move truly dynamic logic into a nested boundary
If one section depends on runtime state, isolate it in a child component so the shell still renders meaningful HTML.
import { Suspense } from 'react'
function ArticleShell({ slug }: { slug: string }) {
return (
<section>
<h1>{slug}</h1>
<p>This part should always render in the server HTML.</p>
</section>
)
}
async function DynamicSection({ slug }: { slug: string }) {
const res = await fetch(`https://example.com/api/stats/${slug}`, {
cache: 'no-store',
})
const stats = await res.json()
return <aside>Views: {stats.views}</aside>
}
export default function Page({ params }: { params: { slug: string } }) {
return (
<main>
<ArticleShell slug={params.slug} />
<Suspense fallback={<p>Loading stats...</p>}>
<DynamicSection slug={params.slug} />
</Suspense>
</main>
)
}
This pattern preserves a visible server-rendered shell even when a nested section remains dynamic.
6. Verify behavior with JavaScript disabled
After applying the changes:
- Run a production build.
- Open the page with JavaScript disabled.
- Inspect the returned HTML.
- Confirm that the server response contains meaningful shell markup, not just empty containers.
npm run build
npm run start
Testing only in dev mode can be misleading because prerendering and streaming behavior differ from production.
7. If needed, provide a deterministic fallback route strategy
If the list of params is not known at build time, reconsider whether the route can truly have a static shell. In that case, your options are usually:
- precompute a known subset with generateStaticParams()
- use revalidate with cacheable fetches
- accept fully dynamic rendering for that route
The important part is consistency. A page cannot promise a static shell while depending on non-deterministic route resolution and runtime-only data in the same render path.
Common Edge Cases
1. The shell renders locally but is blank in production
This often happens because development mode is more forgiving. Always validate with next build and next start.
2. generateStaticParams() exists, but some params still fail
If a user visits a param not returned by generateStaticParams(), the behavior depends on your route configuration. Make sure your fallback expectations match the routing setup.
3. A parent layout forces dynamic rendering
Even if the page looks static, a parent layout.tsx using cookies(), headers(), or force-dynamic can make the whole segment dynamic.
4. Suspense fallback is also effectively empty
If your fallback is null or an empty wrapper, the shell may still appear blank. Use visible server-rendered fallback content.
5. Client components are doing too much
If the visible content is pushed into a client component and the server shell contains only placeholders, disabling JavaScript will reveal the problem immediately.
6. Uncached fetch inside a shared helper
Sometimes the page looks static, but a helper imported by the shell uses cache: 'no-store' or request-bound APIs. Audit helper functions, not just page files.
7. Route params are used in metadata generation with dynamic reads
If generateMetadata() performs runtime-only work, it can influence the segment rendering mode. Keep metadata generation aligned with your static strategy.
FAQ
Why is the page only blank when JavaScript is disabled?
Because the initial HTML sent by the server does not contain meaningful content. With JavaScript enabled, hydration or client-side rendering may fill in the page later. Without JavaScript, that never happens.
Does generateStaticParams() always fix dynamic segment shell issues?
No. It fixes the route param resolution part, but the shell can still be blank if the page or parent layout uses request-time APIs or uncached data that force dynamic rendering.
Should I use force-static or revalidate?
Use force-static when the route should be purely static. Use revalidate when the shell can be cached and periodically refreshed. If the page genuinely depends on per-request data, a static shell may not be possible for that route.
The practical fix for this issue is to make the dynamic segment statically knowable, keep the shell cacheable and server-renderable, and push runtime-only logic behind clear boundaries. Once those constraints are respected, the blank static shell disappears and the page remains usable even with JavaScript turned off.