Partial prerendering replaces the old binary model of rendering. Before, there were 2 cases. Either the entire route was statically rendered at build time or – if any component in the route had a dynamic element – the entire route was dynamically rendered, that is, rendered server-side at request time.
Dynamic rendering is triggered automatically when a component uses:
-
headers,cookiesorconnectionfunction -
searchParamsorparamspage prop draftMode- dynamic fetch (fetch with no cache)
The downside to dynamic rendering is obvious. Components that could be statically rendered aren’t because the route contains a dynamic element. This is exactly what PPR solves. When a route is made up of static and dynamic components, PPR will statically render the static components into a static shell for the route. The dynamic components must be wrapped inside suspense. The suspense fallback is included into the static shell and the dynamic component itself will be streamed in at runtime.
Example
Note: the examples are available on a new repo on github.
First off, we need to turn on PPR. This is simple, add following config option:
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
};
We will create some components to form our static shell:
// components/global/Header.tsx
export default function Header() {
return <header>**** Header ****header>;
}
// components/global/Footer.tsx
export default function Footer() {
return <footer>**** Footer ****footer>;
}
Then we create a dynamic component. We use connection to keep it simple.
// components/ppr/SimpleConnection.tsx
export default async function SimpleConnection() {
await connection();
return <div>Hello world!div>;
}
Finally, we put it all together in a route:
// ❌ this example fails, see below
// app/chapter-12/connection/page.tsx
export default function Page() {
return (
<>
<Header />
<SimpleConnection />
<Footer />
>
);
}
This is not how you should do it. We needed to wrap inside suspense. But I wanted to demonstrate it first without. When we run next build, we get an error and the build fails:
Error: Route "https://dev.to/chapter-12/connection": Uncached data was accessed outside of . This delays the entire page from rendering, resulting in a slow user experience.
...
This is great; when we forget suspense, Next shouts at us! We update the example with suspense:
export default function Page() {
return (
<>
<Header />
<Suspense fallback='...loading'>
<SimpleConnection />
Suspense>
<Footer />
>
);
}
This is pure and simple PPR. Given what we learned we expect 2 things:
- At
build time: A static shell with header, footer and the loading fallback. - At
runtime: the fallback gets swapped with the resolved component.
1. We expect a static shell
We run Next build. The build log gives us the first information:
Route (app)
├ ◐ /chapter-12/connection
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
We have partial prerendering.
Let’s take a look at the static shell now. We know from earlier chapters that Next will generate HTML and rsc at build time. This is also true for PPR. On top of that the build folder is the same. So, we can take a look at it. Here is a cleaned-up version of the prerendered HTML:
lang="en">
**** Header ****
id="B:0">...loading
**** Footer ****
As for the rsc, that’s a bit different. In a previous chapter, we looked at the rsc payload of static rendering. We learned that each route had an HTML and rsc file on the same level. So, for example app/example/page.tsx would be generated as example.html and example.rsc in the build folder .next/server/app.
PPR routes are a bit different. The rsc sits in a subfolder and is divided across multiple files, segments. But that doesn’t really matter. Here is a cleaned-up rsc snippet:
// .nextserverappchapter-12connection.segments_full.segment.rsc
[
['$', 'header', null, { children: '**** Header ****' }],
['$', '$5', null, { fallback: '...loading', children: '$L6' }],
['$', 'footer', null, { children: '**** Footer ****' }],
];
Let’s take a small step back. We’re looking at a route that would’ve rendered dynamically in the previous model because it contains a dynamic element: connection. But we’re now prerendering it partially. Everything in the route that can be statically rendered is prerendered. This is the static shell.
The dynamic parts are skipped. We prerender a placeholder (fallback) into the static shell and when a request is made at runtime, the dynamic component is resolved server side in the background and then streamed to the client where it simply is swapped with the placeholder.
We just looked at these static shells. HTML for initial requests and rsc for client side routing. We saw the static parts: header and footer and the fallback: “…loading”.
2. We expect streaming
The dynamic elements are not prerendered. To render them, we need to run the app. We want to render :
// components/ppr/SimpleConnection.tsx
export default async function SimpleConnection() {
await connection();
return <div>Hello world!div>;
}
We already ran next build. Now we run next start to run the app and visit our route: /chapter-12/connection. We can see the loading state very brief and then it gets swapped with “Hello world!”. So it works.
Everything happens server side at request time. Nothing is saved or cached or put into the build folder so we can’t take a look at it.
What is streamed?
We took a deep look at how static and dynamic rendering works in earlier chapters and we learned that routes are rendered into HTML and rsc files. The HTML and rsc get served on initial load. On client side routing however, only the rsc is used to update routes. We know this. Streaming works similarly.
On initial load, HTML get streamed in. On client side routing, rsc is streamed. We can use our /chapter-12/connection example and look at the network tab in our dev tools to see this. When we’re on the route /chapter-12/connection and refresh the tab (simulate on first load) we see something interesting in our network tab. We’re looking at the root document file (the html file)
When we inspect this file we recognize the prerendered static shell from earlier but also something else:
lang="en">
**** Header ****
id="B:0">
...loading
**** Footer ****
hidden id="S:0">
Hello world!
This part:
hidden id="S:0">
Hello world!
That is our resolved and generated component, inside our static shell. This was streamed in. Next kept the connection open and … added this. I have no idea how these connections work so I can’t explain the how. But it’s the what that is important here. Extra HTML was streamed into the HTML file that Next first sent to the browser.
It is a two part process. First, the static shell that was prerendered at build time was send to the browser. Then, when Next rendered the dynamic component, it sent over some more HTML into the same file. The fallback swap is easy then. Take content of div with ID X and put it into div with ID Y.
So, on initial load, Next renders dynamic elements into HTML and streams it to the browser.
On client side navigation, only rsc is used. I added some links in my examples so we can navigate to our route. We clear our network tab and navigate to route /chapter-12/connection. A single rsc files appears (well, 2 icons as well but meh): http://localhost:3000/chapter-12/connection?_rsc=ob2DjmuOZafxCs0z.
As expected, it’s rsc and it’s complex. But, we can just about make out the original static shell fragments and also content of the resolved component (“Hello world!”). How Next uses this rsc to paint in the browser doesn’t matter. We only wanted to confirm rsc was streamed.
Client components
Maybe you’re wondering how client components fit into the PPR model. They don’t. PPR does not affect client components.
Quick recap: client components are components that use:
- hooks or custom hooks
- interactive element (event listeners)
- browser only APIs (e.g. localStorage)
Dynamic components are components that use dynamic functions (headers, cookies, searchParams or params page prop, connection or draftmode) of dynamic fetches (no caching).
Dynamic components and client components don’t overlap. However, all dynamic functions are asynchronous while client components cannot be async. This means that client components cannot be dynamic. All of this isn’t new.
Client components never require a dynamic suspense boundary – because they can’t be dynamic. Except when using the useSearchParams hook. But we covered this already in a previous chapter.
Client components can of course be inside suspense boundaries as a child of a dynamic component. Take a look at this example:
// app/chapter-12/dynamic-with-client-child/page.tsx
export default function Page() {
return (
<>
<Header />
<Suspense fallback='...loading'>
<DynamicParent />
Suspense>
<Footer />
>
);
}
// components/ppr/DynamicParent.tsx
export default async function DynamicParent() {
await connection();
return (
<div>
<h1>**** Dynamic componenth1>
<ClientChild />
div>
);
}
// components/ppr/ClientChild.tsx
'use client';
import { useState } from 'react';
export default function ClientChild() {
const [count, setCount] = useState(0);
return (
<>
<div>**** count: {count}div>
<button onClick={() => setCount((prev) => prev + 1)}>add onebutton>
>
);
}
So, we have suspense that wraps a dynamic component and said component imports a client component.
Just before, we learned that dynamic components are streamed as HTML on initial load or as rsc on client side routing. But what about our client component?
On initial load, the client component will also be rendered server-side into a non interactive shell, just the HTML, no event listeners:
**** count: {count}
add one
This is streamed to the client, as HTML to the HTML document file, where it is swapped with the fallback. Once the entire client bundle is loaded, Next will virtually run again in the browser and use it to hydrate the prerendered HTML.
On client side routing, the process is different. Only rsc is streamed. The server resolves and renders it into rsc. Inside this rsc, there will be a placeholder that refers to . does not get rendered into rsc itself. The rsc is sent to the client. On the client, is rendered and fills the placeholder. Finally, this rendered chunk ( and ) is swapped with the fallback. Hydration of is not needed since there was no prerendered HTML.
This is just normal client component rendering behaviour. We already covered this in a previous chapter. As I said before, PPR does not affect client components. The only difference is the suspense boundary.
Managing boundaries
In the old model, once you used a dynamic element, the route became dynamic and that was that. Nothing got prerendered. In the PPR model, boundaries determine dynamic rendering and these boundaries need to be managed.
Every dynamic element needs to be resolved: headers, cookies, searchParams page prop, connection and dynamic fetches are all async functions. They return a promise that needs to be resolved. To resolve a promise in PPR, you need to wrap it in suspense (or use loading.ts). Elements wrapped in suspense do not get prerendered. But we want to prerender as much as possible. Therefore, we need to be careful with using suspense.
The first way to optimize this is to place suspense boundaries as far down a component tree as possible. Take a header for example with a logo, a menu and a user account icon. Make the user account icon a separate component and wrap it in suspense. The other elements in the header can then be statically prerendered. We always want to maximize static rendering.
This isn’t always obvious. Here is an example. We use our previous example but rework it with the searchParams page prop instead of connection:
// app/chapter-12/searchparams/page.tsx
// ❌ this example fails, see below
export default async function Page({
searchParams,
}: PageProps<'/chapter-12/searchparams'>) {
const { name } = await searchParams;
return (
<>
<Header />
<Suspense fallback='...loading'>
<SimpleSearchParams name={name} />
</Suspense>
<Footer />
</>
);
}
// components/ppr/SimpleSearchParams.tsx
type Props = {
name: string | string[] | undefined;
};
export default function SimpleSearchParams({ name }: Props) {
const validatedName = !name ? 'guest' : Array.isArray(name) ? name[0] : name;
return <div>Hello {validatedName}div>;
}
This fails at build time. Why? Because we resolved searchParams inside our page component. When Next encounters a promise that needs to be resolved, it marks the component as dynamic and looks for a suspense boundary and we don’t have one. This is the error:
Error: Route "https://dev.to/chapter-12/searchparams": Uncached data was accessed outside of .
A very bad ❌ way to solve this problem would be to add a loading.tsx file in our page route. The error will be gone, but the entire page will be rendered dynamically, including our and components. This is exactly what we don’t want.
The correct ✅ way to solve this is by passing the promise – unresolved – to our component:
export default function Page({
searchParams,
}: PageProps<'/chapter-12/searchparams'>) {
return (
<>
<Header />
<Suspense fallback='...loading'>
<SimpleSearchParams searchParams={searchParams} />
Suspense>
<Footer />
>
);
}
The component is no longer async and we just pass the searchParams promise. We then resolve this promise in our component:
type Props = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function SimpleSearchParams({ searchParams }: Props) {
const { name } = await searchParams;
const validatedName = !name ? 'guest' : Array.isArray(name) ? name[0] : name;
return <div>Hello {validatedName}div>;
}
We run build and everything runs fine, we have a PPR route with maximum static rendering. Passing a promise down is a second tool you can use to maximize static rendering.
Migrating terminology
With PPR, Next changed some terminology. Here are two of those changes:
The terms dynamic elements or dynamic fetches are no longer used. Instead these are now categorized as:
- Streaming uncached data (instead of dynamic fetches).
- Runtime APIs:
headers,cookies,paramsandsearchParamspage props (instead of dynamic elements). - Non-deterministic operations (
Math.random(),Date.now(),crypto.randomUUID()) that requireconnection(instead of dynamic elements).
So, the enveloping term dynamic elements is out of use. I’m not sure this is a good change. It needs to be clear what causes a component to become dynamically rendered and that seems less clear with this change.
Full route cache is no longer mentioned. What used to be the full route cache is now only referred to as the static shells. Again, not a fan of this change. Having a term like full route cache made me understand caching in Next better.
Static, dynamic and partial prerendering
For now we skipped caching and data fetching. We will cover this in later chapters. But, I want to make it clear in this chapter that static rendering still exists in the PPR model. Static rendering, a 100% prerendered route with no dynamic elements or suspense boundaries is still possible. PPR does not eliminate this.
Dynamic rendering has become mostly obsolete by PPR. In most cases we do not want to dynamically render an entire route. We want to prerender as much as possible. That is what PPR allows us to do. However, should you need it, it is still possible to dynamically render a route. Place a suspense with fallback null in the root layout.tsx:
// app/layout.tsx
import { Suspense } from 'react';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<Suspense fallback={null}>
<body>{children}body>
Suspense>
html>
);
}
When Next encounters null as fallback in the root layout, it will not generate a static shell. This means that this route will have to be generated server side at request time = dynamic rendering. Note that this setup will make every route dynamic. You can alter this by using multiple root layouts. Finally, note that these routes will again be blocking. Nothing will be sent over to the client before everything is resolved and generated.
loading.tsx
We’ve mostly been using suspense. Note that you can also use the loading.tsx to add a suspense boundary in PPR. See the docs for more info.
Conclusion
I hope this article gave you a more grounded feel for partial prerendering.
In the next chapters we will cover caching with cache components.
If you want to support my writing, you can donate with paypal.

