DEV Community

Cover image for A guide to enabling partial pre-rendering in Next.js
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

A guide to enabling partial pre-rendering in Next.js

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>
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, in your next.config.js file, add the following configuration:

experimental: {
  ppr: true,
},
Enter fullscreen mode Exit fullscreen mode

Your next.config.js file should look like this:

/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
        ppr: true,
    },
}
module.exports = nextConfig
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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: A Gif Showing The Shimmer Loader Before The Posts Are Loaded

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;
Enter fullscreen mode Exit fullscreen mode

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>
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

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: The Partial Pre-Rendering Of The Blog Posts 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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: PPR Pre-Rendering Different Sections Of The Blog Page 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 Signup

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)