Using Partial Prerendering in NextJS
Introduction
Partial Prerendering (PPR) is the optimal combination of Static Site Generation (SSG) and Dynamic Rendering (SSR) within a single route.
- Previously, a website usually had to choose one of two: either fully static (fast but not personalized) or fully dynamic (slower because of waiting for server processing). With PPR:
- Static Shell: Parts like Header, Sidebar or the frame (Skeleton) are pre-rendered into static HTML files and sent immediately to the user.
- Dynamic Islands: Parts that need actual data (like shopping carts or user info) are pre-rendered on the UI using React Suspense. The server will stream these parts down once the data is ready.
Outstanding Advantages
- Instant response speed: Users see page content almost immediately (static shell) instead of looking at a white screen waiting for the server.
- Optimize user experience: Minimize Layout Shift (jumping content) by pre-defining fallbacks (like Skeleton) in Suspense.
Detail
In this article, I will guide you through all the implementation cases for Partial Prerendering, including usage with async functions, the use hook, the next dynamic function with ssr/loading fields and React.lazy.
Create file app/partial-pre-rendering/service.ts
import {delay} from 'msw'
export async function getRemoteData(time?: number) {
await delay(time || 0)
return {
timestamp: new Date().toISOString(),
}
}
The msw delay function is used to delay the response by the given duration (ms), you can also replace it with a standard await Promise with similar functionality, here I use it to simulate latency to show the Skeleton during the data loading process.
Create file app/partial-pre-rendering/SkeletonColor.tsx
import {Skeleton} from 'antd'
export const SkeletonColor = () => (
<Skeleton
active
styles={{
title: {backgroundColor: '#ffe7ba'},
paragraph: {backgroundColor: '#fff7e6'},
}}
/>
)
In this code I create a different Skeleton to distinguish between loading in a dynamic function and a Suspense fallback, you will see specifically in the example below.
Create file app/partial-pre-rendering/ServerWidget.tsx
import {Card} from 'antd'
import {getRemoteData} from './service'
export async function ServerWidget({
title,
time,
}: {
title: string
time?: number
}) {
const serverData = await getRemoteData(time)
return (
<Card title={title} className="shadow-md border-blue-400">
<div className="mt-2">
<span className="text-gray-600 antialiased">
{serverData.timestamp}
</span>
</div>
</Card>
)
}
export function ServerWidgetNoAsync({
title,
time,
}: {
title: string
time?: number
}) {
return (
<Card title={title} className="shadow-md border-blue-400">
<div className="mt-2">
<span className="text-gray-600 antialiased">{time}</span>
</div>
</Card>
)
}
- Here I define Server Components that run entirely on the server.
- In ServerWidget, a promise function is used to get data so there must be an async when defining the function.
Create file app/partial-pre-rendering/DynamicServerWidget.tsx
import dynamic from 'next/dynamic'
import {SkeletonColor} from './SkeletonColor'
export const DSWSSR = dynamic(
() => import('./ServerWidget').then(mod => mod.ServerWidget),
{
ssr: true,
}
)
export const DSWSSRLoading = dynamic(
() => import('./ServerWidget').then(mod => mod.ServerWidget),
{
ssr: true,
loading: () => <SkeletonColor />,
}
)
This code will create 2 Components with the NextJS dynamic function including with and without loading, the ssr true field (which is the default value) means this component will be rendered at the server.
Create file app/partial-pre-rendering/DynamicServerWidgetClient.tsx
'use client'
import {delay} from 'msw'
import dynamic from 'next/dynamic'
import {SkeletonColor} from './SkeletonColor'
export const DSWNoSSR = dynamic(
async () => {
await delay(4000)
return import('./ServerWidget').then(mod => mod.ServerWidgetNoAsync)
},
{
ssr: false,
}
)
export const DSWNoSSRLoading = dynamic(
async () => {
await delay(4000)
return import('./ServerWidget').then(mod => mod.ServerWidgetNoAsync)
},
{
ssr: false,
loading: () => <SkeletonColor />,
}
)
- The ssr false field means the browser will load the component bundle and then perform rendering.
- Note that I use the ServerWidgetNoAsync component instead of the ServerWidget component, because ServerWidget is defined with async so it cannot be used with ssr false (which is rendered at the client).
- The places where I use delay are to simulate time, in practice you can ignore them.
Create file app/partial-pre-rendering/ClientWidget.tsx
'use client'
import {Card, Tag, Typography} from 'antd'
import {use, useEffect, useState} from 'react'
import {getRemoteData} from './service'
const {Text} = Typography
const promiseData = getRemoteData()
export default function ClientWidget({title}: {title: string}) {
const data = use(promiseData)
const [width, setWidth] = useState<number>(0)
useEffect(() => {
if (typeof window !== 'undefined') {
setWidth(window.innerWidth)
}
}, [])
return (
<Card title={title} className="shadow-md border-orange-400">
<Tag color="orange">{title}</Tag>
{width > 0 && (
<div>
<div className="mt-2">
<Text strong>Window Width: {width}px</Text>
</div>
<div className="mt-2">
<Text>Time: {data.timestamp}</Text>
</div>
</div>
)}
</Card>
)
}
This is a client component, it has the following characteristics:
If you want to use hooks (whether any React Hook or custom hook) or browser windows, you must convert that component into a client component.
- Only in a client component can you use the React use hook, this is a new data loading mechanism that works as follows:
- The Promise is defined at the server so API calls are also at the server.
- Simultaneously, the client will load the JS bundle.
- Until the Promise is finished executing, this component will not be fully executed to show on the UI.
If you import and use a client component within a server component, it will still be rendered on the server first and return data to the browser to show first, then when the javascript bundle is loaded by the browser, React will continue hydration to attach events to DOM elements.
- For reloads (or initial loads), NextJS will render at the server and return HTML data to show on the UI first (good for SEO) then load the JS bundle.
- For navigating between pages, NextJS will return the RSC payload and then check if the JS bundle exists to continue loading.
Create file app/partial-pre-rendering/DynamicClientWidget.tsx
'use client'
import {delay} from 'msw'
import dynamic from 'next/dynamic'
import {SkeletonColor} from './SkeletonColor'
export const DCWSSR = dynamic(
async () => {
await delay(4000)
return import('./ClientWidget')
},
{
ssr: true,
}
)
export const DCWSSRLoading = dynamic(
async () => {
await delay(4000)
return import('./ClientWidget')
},
{
ssr: true,
loading: () => <SkeletonColor />,
}
)
As mentioned, you can still use dynamic with client components and ssr true to render on the server.
Create file app/partial-pre-rendering/DynamicClientWidgetClient.tsx
'use client'
import {delay} from 'msw'
import dynamic from 'next/dynamic'
import {SkeletonColor} from './SkeletonColor'
export const DCWNoSSR = dynamic(
async () => {
await delay(4000)
return import('./ClientWidget')
},
{
ssr: false,
}
)
export const DCWNoSSRLoading = dynamic(
async () => {
await delay(4000)
return import('./ClientWidget')
},
{
ssr: false,
loading: () => <SkeletonColor />,
}
)
This code content defines a client component with ssr false, which is equivalent to using React.lazy.
Create file app/partial-pre-rendering/DynamicClientWidgetLazy.tsx
'use client'
import {Skeleton} from 'antd'
import {delay} from 'msw'
import {lazy, Suspense} from 'react'
export const DCWLazy = lazy(async () => {
await delay(4000)
return import('./ClientWidget')
})
export const DCWLazyLoading = () => {
return (
<Suspense fallback={<Skeleton active />}>
<DCWLazy title="DCWLazy with Suspense" />
</Suspense>
)
}
- Here I define using React.lazy for demo, when using it you must define it as a client component and the component must be wrapped by Suspense.
- If you use NextJS, the dynamic function is optimized and can completely replace React.lazy so in practice you might not need to use React.lazy anymore.
Create file app/partial-pre-rendering/page.tsx
import {Skeleton} from 'antd'
import {Suspense} from 'react'
import ClientWidget from './ClientWidget'
import {DCWSSR, DCWSSRLoading} from './DynamicClientWidget'
import {DCWNoSSR, DCWNoSSRLoading} from './DynamicClientWidgetClient'
import {DCWLazyLoading} from './DynamicClientWidgetLazy'
import {DSWSSR, DSWSSRLoading} from './DynamicServerWidget'
import {DSWNoSSR, DSWNoSSRLoading} from './DynamicServerWidgetClient'
import {ServerWidget} from './ServerWidget'
export default async function PPPPage() {
return (
<main className="min-h-screen bg-gray-50">
<div className="mx-auto p-10 rounded-2xl shadow-sm">
<h1 className="text-xl font-bold text-gray-800">Server Components</h1>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<ServerWidget title="ServerWidget" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<ServerWidget title="ServerWidget with Suspense" time={4000} />
</Suspense>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DSWSSR title="DSWSSR" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DSWSSR title="DSWSSR with Suspense" time={4000} />
</Suspense>
</div>
</section>
<section className="grid grid-cols-3 gap-6">
<div className="mt-8">
<DSWSSRLoading title="DSWSSRLoading" />
</div>
<div className="mt-8">
<DSWSSRLoading title="DSWSSRLoading" time={4000} />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DSWSSRLoading title="DSWSSRLoading with Suspense" time={4000} />
</Suspense>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DSWNoSSR title="DSWNoSSR" time={4000} />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DSWNoSSR title="DSWNoSSR with Suspense" time={4000} />
</Suspense>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DSWNoSSRLoading title="DSWNoSSRLoading" time={4000} />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DSWNoSSRLoading
title="DSWNoSSRLoading with Suspense"
time={4000}
/>
</Suspense>
</div>
</section>
<h1 className="text-xl font-bold text-gray-800 mt-8">
Client Components
</h1>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<ClientWidget title="ClientWidget" />
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DCWSSR title="DCWSSR" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DCWSSR title="DCWSSR with Suspense" />
</Suspense>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DCWSSRLoading title="DCWSSRLoading" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DCWSSRLoading title="DCWSSRLoading with Suspense" />
</Suspense>
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DCWNoSSR title="DCWNoSSR" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DCWNoSSR title="DCWNoSSR with Suspense" />
</Suspense>
</div>
</section>
<section className="grid grid-cols-1 gap-6">
<div className="mt-8">
<DCWLazyLoading />
</div>
</section>
<section className="grid grid-cols-2 gap-6">
<div className="mt-8">
<DCWNoSSRLoading title="DCWNoSSRLoading" />
</div>
<div className="mt-8">
<Suspense fallback={<Skeleton active />}>
<DCWNoSSRLoading title="DCWNoSSRLoading with Suspense" />
</Suspense>
</div>
</section>
</div>
</main>
)
}
This is where I summarize the use of components defined above.
For server components:
ServerWidget: is a server component that will load entirely on the server.
- ServerWidget when used with Suspense is the mechanism of Partial Pre Rendering, meaning if the server has not finished rendering it will return the fallback in Suspense, until the Server completes then NextJS will replace the content in the fallback with the result after rendering.
DSWSSR: is a server component used with dynamic so it also works like a server component.
- DSWSSR with Suspense also works according to Partial Pre Rendering.
DSWSSRLoading is created using a server component and dynamic with the loading field, so it will work just like you wrap it with Suspense.
- If you wrap another Suspense outside DSWSSRLoading, it will still take according to the dynamic loading field (which is the nearest Suspense wrapping it).
DSWNoSSR: is created from a server component and dynamic ssr false and no loading, so this component will be separated into its own JS bundle and only show on the UI after this bundle is loaded.
DSWNoSSRLoading: is a server component created with dynamic ssr false and loading, then during the browser loading the JS bundle it will show dynamic loading.
For client components:
ClientWidget: is a client component used directly in a server component, meaning it is still rendered on the server returning HTML to show on the UI, while its JS is contained in the application main JS bundle.
For DCWSSR and DCWSSRLoading:
- DCWSSR is a client component with dynamic ssr true and no loading.
- DCWSSRLoading is a client component with dynamic ssr true with loading.
- They work similarly in that both are rendered on the server but separate the JS bundle for the client for hydration.
- For reloads or initial loads, you will not see Suspense working because it has already been rendered on the server and returned.
- But if it is page navigation, during the JS bundle download it will show the Suspense fallback.
The following components work the same way:
- DCWNoSSR is a client component with dynamic ssr false and no loading.
- DCWNoSSRLoading is a client component with dynamic ssr false with loading.
- DCWLazyLoading is a component created by React.lazy, the dynamic function has wrapped React.lazy and added various support options for both server and client components, so in a NextJS project you may not need to use React.lazy anymore.
- The browser must load the JS bundle of these components before it can show on the UI.
The result is as follows:
Happy coding!
Comments
Post a Comment