Written by Chimezie Innocent✏️
Next.js 14 introduced a new feature called partial pre-rendering. Partial pre-rendering allows developers to control which part of a page is pre-rendered or rendered first. It uses the React Suspense API, so it should be easy to understand and use if you've used React.
Partial pre-rendering leverages a combination of static processing, specifically incremental static regeneration (ISR) and full server-side processing (SSR). With ISR, Next.js enables the pre-rendering of pages containing dynamic data during the build time. Subsequently, these pages are incrementally re-rendered in the background when needed, providing an efficient and dynamic user experience.
In this article, we will explore how the partial pre-rendering feature works and how it can be used in Next.js applications. Keep in mind that this feature is still in the experimental phase and therefore, not recommended for use in a production environment yet.
How does partial pre-rendering work?
Partial pre-rendering in Next.js 14 allows developers to specify which parts or sections of a page should be pre-rendered, giving developers more control over the optimization process. The partial pre-rendering feature leverages React's Concurrent API and Suspense to “suspend” or "pause" rendering until the data is ready and available, leading to a faster and more optimized performance.
The React Suspense API allows components to suspend or pause rendering while awaiting data, usually during asynchronous data fetching.
The Suspense API provides a fallback UI that appears while the data is loading. This fallback UI is loaded along with other static contents into the page. This means that as the page loads, the fallback UI is displayed with other contents that are not dynamically generated. The fallback UI then remains visible until the asynchronous data fetching is complete. Once the data is ready, the fallback UI is replaced with the data.
The fallback UI is usually a loader that we want to show our users to let them know that contents are being loaded so they can keep going through other parts of the page that are already loaded or pre-rendered.
To use partial pre-rendering, first determine the sections or parts in your application where the asynchronous operations are taking place. This is the ideal place that you want to apply the Suspense API to. For example, a component that fetches data asynchronously can be wrapped with the <Suspense>
component, indicating that the part should be suspended or delayed until the data is ready and available.
Let's look at the code below:
import React, { Suspense } from "react"
const App = () => (
<Suspense fallback={<Loader />}>
....your component
</Suspense>
)
The components wrapped within or inside the Suspense
component are the ones that will be suspended. You don't need to change your code to use partial pre-rendering at all — you only need to wrap the section or page with Suspense
and Next.js will know which parts to render static or dynamic.
How to use partial pre-rendering
To use the partial pre-rendering feature in Next.js, install the latest version of Canary using any of the commands below:
/* using npm */
npm install next@canary
/* using yarn */
yarn add next@canary
Next, in your next.config.js
file, add the following configuration:
experimental: {
ppr: true,
},
Your next.config.js
file should look like this:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
},
}
module.exports = nextConfig
Now we can use the Suspense API. Let's look at the code example below:
async function Posts() {
const data = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
cache: 'no-store',
})
const posts = await data.json()
return (
<>
<h2>All Posts</h2>
{posts.slice(0, 7).map((post) => (
<div key={post.id}>
<h4>Title: {post.title}</h4>
<p>Content: {post.body}</p>
</div>
))}
</>
)
}
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<h1>Partial Pre-Rendering</h1>
<p>
Morbi eu ullamcorper urna, a condimentum massa. In
fermentum ante non turpis cursus fringilla. Praesent
neque eros, gravida vel ante sed, vehicula elementum
orci. Sed eu ipsum eget enim mattis mollis. Morbi eu
ullamcorper urna, a condimentum massa. In fermentum ante
non turpis cursus fringilla. Praesent neque eros,
gravida vel ante sed, vehicula elementum orci. Sed eu
ipsum eget enim mattis mollis.
</p>
</div>
<Posts />
</main>
)
}
In the above code, we have a simple page fetching and rendering some posts. With partial pre-rendering, we can defer the posts’ content until the data is available. During the fetching time, the fallback we specify will be rendered with the other static contents:
import { Suspense } from 'react'
function LoadingPosts() {
const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`
return (
<div className="col-span-4 space-y-4 lg:col-span-1 min-h-screen w-full mt-20">
<div
className={`relative h-[167px] rounded-xl bg-gray-900 ${shimmer}`}
/>
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-6 w-1/3 rounded-lg bg-gray-900" />
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-4 w-4/6 rounded-lg bg-gray-900" />
</div>
)
}
async function Posts() {
const data = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
cache: 'no-store',
})
const posts = await data.json()
return (
<>
<h2>All Posts</h2>
{posts.slice(0, 7).map((post) => (
<div key={post.id}>
<h4>Title: {post.title}</h4>
<p>Content: {post.body}</p>
</div>
))}
</>
)
}
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<h1>Partial Pre-Rendering</h1>
<p>
Morbi eu ullamcorper urna, a condimentum massa. In
fermentum ante non turpis cursus fringilla. Praesent
neque eros, gravida vel ante sed, vehicula elementum
orci. Sed eu ipsum eget enim mattis mollis. Morbi eu
ullamcorper urna, a condimentum massa. In fermentum ante
non turpis cursus fringilla. Praesent neque eros,
gravida vel ante sed, vehicula elementum orci. Sed eu
ipsum eget enim mattis mollis.
</p>
</div>
<Suspense fallback={<LoadingPosts />}>
<Posts />
</Suspense>
</main>
)
}
We wrapped the Posts
component with the Suspense API we imported from React and added a fallback UI of the LoadingPosts
component.
The LoadingPosts
component represents the loading skeleton for the posts. It includes a shimmer effect (commonly used as a loading animation) and is styled to give users visual feedback that content is being loaded. If you reload your page, you should see the loading skeleton for a minute before the posts' contents are rendered:
Use case for partial pre-rendering
As we discussed earlier, pages with dynamic data loading are the best use case for partial pre-rendering because the data is fetched asynchronously. Let’s look at a good use case where we can leverage the partial pre-rendering feature.
Partial pre-rendering of a blog page
In every blog website, we have a list of blogs that we fetch from our server. With PPR, we can display a loader as the blog posts are being fetched and subsequently replaced when the data is ready.
By wrapping the asynchronous data fetching with Suspense
, you suspend the rendering of the component until the data is available. This approach optimizes the initial page loading efficiency by pre-rendering static content, including the fallback, and only fetches and renders the dynamic data when needed.
Let's look at the example below:
/* pages.js */
import Home from './components/Home';
const Page = () => (
<main className="flex min-h-screen flex-col justify-between p-12">
<header className="mb-12 text-center">
<h1 className="mb-6 font-bold text-3xl">MezieIV Blog</h1>
</header>
<Home />
<footer className="mt-24 text-center">
<p>©MezieIV 2023</p>
</footer>
</main>
);
export default Page;
We are using the App
router for this tutorial. In the code above, we have a header, body, and footer in our page layout. We are importing the Home
component, which will contain our blog post.
In your Home
component, copy and paste the code below:
/* /components/Home.js */
import { Suspense } from 'react';
function LoadingPosts() {
const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`;
return (
<div className="col-span-4 space-y-4 lg:col-span-1 min-h-screen w-full mt-20">
<div
className={`relative h-[167px] rounded-xl bg-gray-900 ${shimmer}`}
/>
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-6 w-1/3 rounded-lg bg-gray-900" />
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-4 w-4/6 rounded-lg bg-gray-900" />
</div>
);
}
async function Posts() {
const data = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
cache: 'no-store',
});
const posts = await data.json();
return (
<>
<h2 className="mb-3 mt-8 font-bold text-2xl">All Posts</h2>
{posts.slice(0, 7).map((post) => (
<div
key={post.id}
className="mb-5"
>
<h4 className="text-lg">Title: {post.title}</h4>
<p className="text-sm">Content: {post.body}</p>
</div>
))}
</>
);
}
export default function Home() {
return (
<>
<div>
<h2 className="mb-3 font-bold text-2xl">
Partial Pre-Rendering
</h2>
<p>
Morbi eu ullamcorper urna, a condimentum massa. In fermentum
ante non turpis cursus fringilla. Praesent neque eros,
gravida vel ante sed, vehicula elementum orci. Sed eu ipsum
eget enim mattis mollis. Morbi eu ullamcorper urna, a
condimentum massa. In fermentum ante non turpis cursus
fringilla. Praesent neque eros, gravida vel ante sed,
vehicula elementum orci. Sed eu ipsum eget enim mattis
mollis.
</p>
</div>
<Suspense fallback={<LoadingPosts />}>
<Posts />
</Suspense>
</>
);
}
The page header and paragraphs are pre-rendered, as well as the fallback UI. When we load our page, we can see the loader displayed until the fetch is completed and the blog posts are ready: Let’s take our use case a step further. In the example above, we only have one section that uses the Suspense API of PPR.
After a user clicks on a blog, they are taken to the blog page to continue reading the blog post. On the blog page, we want the user to have the blog post as well as an aside of similar blogs or recent blog posts. This is a good user experience in a real-world scenario so that the user doesn’t need to go back to find another post to read.
Because both the main blog and the aside blog posts are fetched dynamically, we can use the Suspense API for both sections.
Still inside the Home
component, you can copy and replace with the code below:
/* /components/Home.js */
import { Suspense } from 'react';
function LoadingPosts() {
const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`;
return (
<div className="col-span-4 space-y-4 lg:col-span-1 w-full mt-20">
<div
className={`relative h-[167px] rounded-xl bg-gray-900 ${shimmer}`}
/>
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-6 w-1/3 rounded-lg bg-gray-900" />
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-4 w-4/6 rounded-lg bg-gray-900" />
</div>
);
}
async function fetchPosts() {
return new Promise((resolve) => {
setTimeout(async () => {
const data = await fetch(
`https://jsonplaceholder.typicode.com/posts`,
{
cache: 'no-store',
}
);
const posts = await data.json();
resolve(posts);
}, 2000);
});
}
async function BlogPost() {
const posts = await fetchPosts();
const post = posts[0];
return (
<div className="w-full">
<h4 className="text-lg mb-2">Title - {post.title}</h4>
<p className="text-sm leading-6">
{post.body} {post.body} {post.body} {post.body} {post.body}{' '}
{post.body} {post.body} {post.body} {post.body} {post.body}
</p>
</div>
);
}
async function Aside() {
const posts = await fetchPosts();
return (
<aside className="w-full">
<div>
{posts.slice(0, 5).map((post) => (
<ol
key={post.id}
style={{ listStyle: 'inside' }}
>
<li className="text-lg w-full">
<a href="#">{post.title}</a>
</li>
</ol>
))}
</div>
</aside>
);
}
export default function Home() {
return (
<div className="flex justify-between pl-12 pr-12">
<div className="w-[70%]">
<h2 className="text-2xl mb-6">Main Blog</h2>
<Suspense fallback={<LoadingPosts />}>
<BlogPost />
</Suspense>
</div>
<div className="w-[25%] pl-10">
<h2 className="text-2xl mb-12">Latest Blog Posts</h2>
<Suspense fallback={<LoadingPosts />}>
<Aside />
</Suspense>
</div>
</div>
);
}
As you can see, we wrapped the BlogPost
and the Aside
components with two separate Suspense APIs. We are using the same loader skeleton for both but you can design yours based on how your UI is designed so that it looks much more compatible and aesthetically pleasing.
The result will look like the gif below: You can find the full code here.
Another example use case of PPR is an Admin Dashboard. Dashboards have different sections consisting of graphs, bar and pie charts, lists of information, and so on. In such cases, you can use the partial pre-rendering capability on the sections that are fetched asynchronously. Similarly, the sections that are not being fetched asynchronously will be pre-rendered statically at build time.
Benefits of partial pre-rendering
From what we have learned in this tutorial, we can deduce that partial pre-rendering has the following benefits:
- Faster initial page load: Because static contents are pre-rendered instantly as the page loads, users don’t have to wait until all content, including the dynamic contents, is loaded. This leads to faster page loading — users can continue interacting with the static parts of the page while the dynamic contents are fetched in the background
- Improved user experience: Dynamic contents are loaded seamlessly when it is ready. In the meantime, a loader is shown to the users to imply data is being fetched. This improves the site interactivity experience for users
- Reduced server load: PPR reduces the load on the server. This is because the server only renders the dynamic sections of the page as PPR pre-renders the static contents on build time and subsequently, from a cache
- Resource utilization: PPR combines the capabilities of static (ISR) and dynamic rendering (SSR). So, you can choose which parts of your page will be static and pre-rendered, and which parts will be dynamic
Conclusion
In this article, we looked at partial pre-rendering and how it works in a Next.js application.
Partial pre-rendering is beneficial in scenarios where parts of your website are rendered dynamically or where data is fetched asynchronously. Using PPR, you choose which parts of your page should be pre-rendered and which parts should be loaded on demand. This allows for a faster initial page load, as users see static or pre-rendered content immediately, and dynamic content is loaded when needed or ready.
Although it is still in its initial and experimental stage, and not advisable to use it in production, you can always try it out to get a glance at what a stable version will look like.
LogRocket: Full visibility into production Next.js apps
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Top comments (0)