Next.js 15 Partial Prerendering (PPR): Build a Blazing-Fast Dashboard with Hybrid Rendering

Static speed. Dynamic power. One page. Next.js 15 Partial Prerendering (PPR) is the most significant rendering innovation since Server Components. In this tutorial, you will build an analytics dashboard that loads instantly with a static shell while streaming personalized, real-time data — all without client-side JavaScript waterfalls.
What You Will Learn
By the end of this tutorial, you will:
- Understand what PPR is and how it differs from SSR, SSG, and ISR
- Enable PPR in a Next.js 15 project with the experimental flag
- Build a static shell that loads in milliseconds from the CDN edge
- Use React Suspense boundaries to define dynamic "holes" in static pages
- Stream personalized content (user data, live metrics) into those holes
- Implement fallback loading states for each dynamic section
- Measure real performance gains with Core Web Vitals
- Deploy a PPR-enabled app to Vercel with edge caching
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - Next.js 15 familiarity (App Router, Server Components, layouts)
- React 19 basics (Suspense, async components)
- TypeScript experience
- A code editor — VS Code or Cursor recommended
Understanding Partial Prerendering
The Rendering Problem
Traditional rendering strategies force you to choose between speed and dynamism for an entire page:
| Strategy | Speed | Dynamic Data | Personalization |
|---|---|---|---|
| SSG (Static) | Instant | No | No |
| SSR (Server) | Slower | Yes | Yes |
| ISR (Incremental) | Fast | Stale data | No |
| CSR (Client) | Slow initial | Yes | Yes |
Most real-world pages have both static and dynamic parts. A dashboard has a static navigation bar, static layout, and static labels — but the charts, user greeting, and live metrics are dynamic. Before PPR, you had to render the entire page dynamically just because one section needed fresh data.
How PPR Solves This
Partial Prerendering lets you prerender the static parts of a page at build time while leaving dynamic "holes" that get filled in at request time via streaming.
Here is what happens when a user requests a PPR-enabled page:
- The CDN instantly serves the static HTML shell (navigation, layout, headings, fallbacks)
- The browser renders this shell immediately — the user sees content in milliseconds
- The server streams dynamic content into Suspense boundaries as each piece resolves
- The page progressively fills in without any full-page loading state
The result: TTFB of a static page with the freshness of a dynamic page.
PPR vs. Traditional Streaming SSR
You might wonder: "How is this different from regular streaming with Suspense?"
With traditional streaming SSR, the entire page is rendered on-demand at request time. The server sends the shell and streams in chunks, but nothing is prerendered — the TTFB still depends on server response time.
With PPR, the static shell is prerendered and cached at the edge. Only the dynamic holes require server computation. This means:
- The static shell comes from the CDN (5-20ms) instead of the origin server (50-300ms)
- Static parts are guaranteed consistent — no server computation variance
- Dynamic parts stream in independently, so a slow database query does not block the whole page
Step 1: Create the Project
Start by creating a fresh Next.js 15 project:
npx create-next-app@latest ppr-dashboard --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd ppr-dashboardVerify your Next.js version is 15 or later:
npx next --versionStep 2: Enable Partial Prerendering
PPR is an experimental feature in Next.js 15. Enable it in your next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;That is all it takes to enable PPR globally. Once enabled, Next.js will automatically prerender the static parts of every page and leave Suspense boundaries as dynamic holes.
Important: PPR requires the App Router. It does not work with the Pages Router. Make sure all your pages use the app/ directory.
Step 3: Understand the Static vs. Dynamic Boundary
Before building the dashboard, let us understand the key concept: how Next.js decides what is static and what is dynamic.
Static by Default
In Next.js 15 with PPR, everything is static unless it opts into dynamic rendering. A component becomes dynamic when it:
- Calls
cookies()orheaders() - Uses
searchParams - Calls
fetch()withcache: "no-store"ornext: { revalidate: 0 } - Uses
unstable_noStore()(nowconnection()in Next.js 15)
The Suspense Boundary Rule
A dynamic component must be wrapped in a <Suspense> boundary. This boundary is what tells PPR: "Prerender everything outside this boundary, and stream this part in at request time."
// This layout is STATIC — prerendered at build time
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar /> {/* Static — prerendered */}
<main>{children}</main>
</div>
);
}
// This page has BOTH static and dynamic parts
export default function Page() {
return (
<div>
<h1>Dashboard</h1> {/* Static — prerendered */}
<StaticChart /> {/* Static — prerendered */}
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics /> {/* Dynamic — streamed */}
</Suspense>
</div>
);
}Step 4: Build the Data Layer
Create a simulated data layer that mimics real API calls with realistic delays. In production, these would be your database queries or API calls.
// src/lib/data.ts
// Simulate network delay
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type Metric = {
label: string;
value: string;
change: number;
trend: "up" | "down" | "flat";
};
export type ChartDataPoint = {
date: string;
visitors: number;
pageViews: number;
bounceRate: number;
};
export type Activity = {
id: string;
user: string;
action: string;
timestamp: string;
avatar: string;
};
// STATIC: Does not change between requests
export function getStaticMetadata() {
return {
dashboardName: "Analytics Overview",
version: "2.4.1",
lastDeployment: "2026-03-20",
sections: ["Metrics", "Traffic", "Activity", "Performance"],
};
}
// DYNAMIC: Simulates fetching live KPI metrics (fast: ~200ms)
export async function getLiveMetrics(): Promise<Metric[]> {
await delay(200);
return [
{
label: "Total Visitors",
value: (Math.floor(Math.random() * 50000) + 10000).toLocaleString(),
change: +(Math.random() * 20 - 5).toFixed(1),
trend: Math.random() > 0.3 ? "up" : "down",
},
{
label: "Page Views",
value: (Math.floor(Math.random() * 150000) + 30000).toLocaleString(),
change: +(Math.random() * 15 - 3).toFixed(1),
trend: Math.random() > 0.4 ? "up" : "down",
},
{
label: "Bounce Rate",
value: (Math.random() * 30 + 20).toFixed(1) + "%",
change: +(Math.random() * 5 - 8).toFixed(1),
trend: Math.random() > 0.5 ? "down" : "up",
},
{
label: "Avg. Session",
value: Math.floor(Math.random() * 5 + 2) + "m " + Math.floor(Math.random() * 59) + "s",
change: +(Math.random() * 10 - 2).toFixed(1),
trend: "up",
},
];
}
// DYNAMIC: Simulates fetching chart data (medium: ~500ms)
export async function getTrafficData(): Promise<ChartDataPoint[]> {
await delay(500);
const days = 14;
const data: ChartDataPoint[] = [];
for (let i = days; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
data.push({
date: date.toISOString().split("T")[0],
visitors: Math.floor(Math.random() * 3000) + 1000,
pageViews: Math.floor(Math.random() * 8000) + 2000,
bounceRate: +(Math.random() * 20 + 25).toFixed(1),
});
}
return data;
}
// DYNAMIC: Simulates fetching recent activity (slow: ~800ms)
export async function getRecentActivity(): Promise<Activity[]> {
await delay(800);
const actions = [
"signed up",
"purchased Pro plan",
"submitted a form",
"left a review",
"upgraded account",
"invited a team member",
"exported data",
"created a project",
];
const names = [
"Sarah Chen", "Marcus Johnson", "Aisha Patel",
"Carlos Rivera", "Emma Wilson", "Omar Hassan",
"Yuki Tanaka", "Fatima Al-Rashid",
];
return Array.from({ length: 8 }, (_, i) => ({
id: `act-${i}`,
user: names[i],
action: actions[i],
timestamp: `${Math.floor(Math.random() * 59) + 1}m ago`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${names[i]}`,
}));
}
// DYNAMIC: Simulates personalized user greeting (fast: ~100ms)
export async function getUserGreeting(): Promise<{
name: string;
role: string;
notifications: number;
}> {
await delay(100);
return {
name: "Alex",
role: "Admin",
notifications: Math.floor(Math.random() * 12),
};
}Notice how each function has a different delay. This is intentional — in a real app, different data sources have different latencies. PPR handles this gracefully because each Suspense boundary resolves independently.
Step 5: Create Skeleton Components
Build loading skeletons that display while dynamic content streams in. These are the fallbacks users see in the static shell.
// src/components/skeletons.tsx
export function MetricsSkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse"
>
<div className="h-4 w-24 rounded bg-gray-200 mb-3" />
<div className="h-8 w-32 rounded bg-gray-200 mb-2" />
<div className="h-3 w-16 rounded bg-gray-200" />
</div>
))}
</div>
);
}
export function ChartSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-40 rounded bg-gray-200 mb-6" />
<div className="flex items-end gap-2 h-64">
{Array.from({ length: 14 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-gray-200"
style={{ height: `${Math.random() * 80 + 20}%` }}
/>
))}
</div>
</div>
);
}
export function ActivitySkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 animate-pulse">
<div className="h-5 w-36 rounded bg-gray-200 mb-6" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div className="flex-1">
<div className="h-4 w-48 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
))}
</div>
</div>
);
}
export function GreetingSkeleton() {
return (
<div className="flex items-center gap-3 animate-pulse">
<div className="h-10 w-10 rounded-full bg-gray-200" />
<div>
<div className="h-5 w-32 rounded bg-gray-200 mb-1" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
</div>
);
}Design tip: Good skeletons match the exact layout of the content they replace. This prevents layout shift (CLS) when dynamic content streams in, which is critical for Core Web Vitals.
Step 6: Build Dynamic Components
Now create the async Server Components that fetch dynamic data. These components will be wrapped in Suspense boundaries.
User Greeting
// src/components/user-greeting.tsx
import { getUserGreeting } from "@/lib/data";
export async function UserGreeting() {
const user = await getUserGreeting();
return (
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{user.name[0]}
</div>
<div>
<p className="font-semibold text-gray-900">
Welcome back, {user.name}
</p>
<p className="text-sm text-gray-500">{user.role}</p>
</div>
{user.notifications > 0 && (
<span className="ml-2 inline-flex items-center justify-center h-6 w-6 rounded-full bg-red-500 text-white text-xs font-bold">
{user.notifications}
</span>
)}
</div>
);
}Live Metrics Cards
// src/components/live-metrics.tsx
import { getLiveMetrics } from "@/lib/data";
import type { Metric } from "@/lib/data";
function MetricCard({ metric }: { metric: Metric }) {
const isPositive =
(metric.trend === "up" && metric.label !== "Bounce Rate") ||
(metric.trend === "down" && metric.label === "Bounce Rate");
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 hover:shadow-md transition-shadow">
<p className="text-sm font-medium text-gray-500">{metric.label}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{metric.value}</p>
<div className="mt-2 flex items-center gap-1">
<span
className={`text-sm font-medium ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? "↑" : "↓"} {Math.abs(metric.change)}%
</span>
<span className="text-sm text-gray-400">vs last week</span>
</div>
</div>
);
}
export async function LiveMetrics() {
const metrics = await getLiveMetrics();
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<MetricCard key={metric.label} metric={metric} />
))}
</div>
);
}Traffic Chart
// src/components/traffic-chart.tsx
import { getTrafficData } from "@/lib/data";
export async function TrafficChart() {
const data = await getTrafficData();
const maxVisitors = Math.max(...data.map((d) => d.visitors));
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">
Traffic Overview
</h3>
<span className="text-sm text-gray-500">Last 14 days</span>
</div>
<div className="flex items-end gap-1.5 h-64">
{data.map((point) => {
const height = (point.visitors / maxVisitors) * 100;
return (
<div
key={point.date}
className="group relative flex-1 flex flex-col items-center"
>
<div className="absolute -top-10 hidden group-hover:block bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{point.visitors.toLocaleString()} visitors
</div>
<div
className="w-full rounded-t bg-gradient-to-t from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 transition-colors cursor-pointer"
style={{ height: `${height}%` }}
/>
<span className="mt-2 text-[10px] text-gray-400">
{new Date(point.date).getDate()}
</span>
</div>
);
})}
</div>
</div>
);
}Recent Activity Feed
// src/components/recent-activity.tsx
import { getRecentActivity } from "@/lib/data";
export async function RecentActivity() {
const activities = await getRecentActivity();
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
Recent Activity
</h3>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center gap-3">
<img
src={activity.avatar}
alt={activity.user}
className="h-10 w-10 rounded-full bg-gray-100"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900">
<span className="font-medium">{activity.user}</span>{" "}
{activity.action}
</p>
<p className="text-xs text-gray-500">{activity.timestamp}</p>
</div>
</div>
))}
</div>
</div>
);
}Step 7: Compose the Dashboard Page
Now bring everything together. This is where the magic of PPR happens — you combine static elements with Suspense-wrapped dynamic components in a single page.
// src/app/page.tsx
import { Suspense } from "react";
import { getStaticMetadata } from "@/lib/data";
import { UserGreeting } from "@/components/user-greeting";
import { LiveMetrics } from "@/components/live-metrics";
import { TrafficChart } from "@/components/traffic-chart";
import { RecentActivity } from "@/components/recent-activity";
import {
MetricsSkeleton,
ChartSkeleton,
ActivitySkeleton,
GreetingSkeleton,
} from "@/components/skeletons";
export default function DashboardPage() {
// This runs at BUILD TIME — completely static
const metadata = getStaticMetadata();
return (
<div className="min-h-screen bg-gray-50">
{/* STATIC: Navigation bar — prerendered at build time */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-600 to-purple-600" />
<h1 className="text-xl font-bold text-gray-900">
{metadata.dashboardName}
</h1>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
v{metadata.version}
</span>
</div>
{/* DYNAMIC: User greeting — streamed at request time */}
<Suspense fallback={<GreetingSkeleton />}>
<UserGreeting />
</Suspense>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* STATIC: Section tabs — prerendered */}
<nav className="flex gap-1 bg-gray-100 p-1 rounded-lg w-fit">
{metadata.sections.map((section) => (
<button
key={section}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
section === "Metrics"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-900"
}`}
>
{section}
</button>
))}
</nav>
{/* DYNAMIC: Live metrics cards — streamed (~200ms) */}
<section>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Key Metrics
</h2>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</section>
{/* Layout with chart and activity side by side */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* DYNAMIC: Traffic chart — streamed (~500ms) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<TrafficChart />
</Suspense>
</div>
{/* DYNAMIC: Recent activity — streamed (~800ms) */}
<div>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
{/* STATIC: Footer — prerendered */}
<footer className="text-center text-sm text-gray-400 pt-8 border-t border-gray-200">
Last deployment: {metadata.lastDeployment} · Built with Next.js 15 PPR
</footer>
</main>
</div>
);
}What Happens at Build Time
When you run next build, Next.js analyzes this page and produces:
- Static HTML for: the header, navigation tabs, section headings, footer, and all skeleton fallbacks
- Dynamic holes for:
UserGreeting,LiveMetrics,TrafficChart,RecentActivity
The static HTML is cached on the CDN. When a user visits the page:
- 0ms: CDN serves the static shell — the user immediately sees a fully-laid-out dashboard with loading skeletons
- ~100ms: User greeting streams in and replaces
GreetingSkeleton - ~200ms: Metric cards stream in and replace
MetricsSkeleton - ~500ms: Traffic chart streams in and replace
ChartSkeleton - ~800ms: Activity feed streams in and replaces
ActivitySkeleton
Each section appears independently as its data resolves. No waterfalls. No blocking.
Step 8: Add Per-Route PPR Configuration
Sometimes you want PPR on specific routes instead of globally. Next.js 15 supports per-route configuration with the experimental_ppr route segment config:
// src/app/settings/page.tsx
// Opt this specific page INTO PPR (if not enabled globally)
export const experimental_ppr = true;
export default function SettingsPage() {
return (
<div>
<h1>Settings</h1>
<Suspense fallback={<div>Loading preferences...</div>}>
<UserPreferences />
</Suspense>
</div>
);
}You can also mix PPR and fully dynamic pages in the same app:
// src/app/api/webhook/route.ts
// API routes are always dynamic — PPR does not apply to Route Handlers
export async function POST(request: Request) {
const body = await request.json();
// Process webhook...
return Response.json({ received: true });
}Step 9: Handle Dynamic Data Patterns
Using connection() for Dynamic Opt-In
In Next.js 15, the recommended way to mark a component as dynamic is the connection() function (replacing the older unstable_noStore()):
// src/components/server-time.tsx
import { connection } from "next/server";
export async function ServerTime() {
// This tells Next.js: "This component needs fresh data every request"
await connection();
const now = new Date();
return (
<p className="text-sm text-gray-500">
Server time: {now.toLocaleTimeString()}
</p>
);
}Reading Cookies and Headers
Components that read cookies or headers are automatically dynamic:
// src/components/theme-aware-panel.tsx
import { cookies } from "next/headers";
export async function ThemeAwarePanel() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-gray-900"}>
<p>Your preferred theme: {theme}</p>
</div>
);
}Using Search Params
Components that access searchParams trigger dynamic rendering:
// src/app/dashboard/page.tsx
import { Suspense } from "react";
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const { range } = await searchParams;
return (
<div>
<h1>Dashboard</h1> {/* Static */}
<Suspense fallback={<div>Loading data...</div>}>
<DataPanel range={range ?? "7d"} /> {/* Dynamic */}
</Suspense>
</div>
);
}Step 10: Nested Suspense for Granular Streaming
One powerful PPR pattern is nested Suspense boundaries. This lets fast data appear first while slower data continues loading:
// src/components/analytics-panel.tsx
import { Suspense } from "react";
export function AnalyticsPanel() {
return (
<div className="space-y-6">
{/* Fast data loads first */}
<Suspense fallback={<div className="h-20 animate-pulse bg-gray-100 rounded" />}>
<QuickStats /> {/* ~100ms */}
</Suspense>
{/* Medium data loads second */}
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded" />}>
<DetailedChart /> {/* ~400ms */}
{/* Nested: slow data loads last, but does not block the chart */}
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100 rounded" />}>
<DeepAnalysis /> {/* ~1200ms */}
</Suspense>
</Suspense>
</div>
);
}With nested Suspense, QuickStats streams in at ~100ms, DetailedChart at ~400ms, and DeepAnalysis at ~1200ms — all independently. Without nesting, DeepAnalysis would block DetailedChart from appearing.
Step 11: Error Handling with PPR
Dynamic components can fail. Use React error boundaries alongside Suspense to handle errors gracefully in PPR:
// src/app/error-boundary.tsx
"use client";
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Use it alongside Suspense:
// src/app/page.tsx
<ErrorBoundary
fallback={
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-red-700">
Failed to load metrics. Please refresh.
</div>
}
>
<Suspense fallback={<MetricsSkeleton />}>
<LiveMetrics />
</Suspense>
</ErrorBoundary>Or use Next.js built-in error.tsx for route-level error handling:
// src/app/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Something went wrong
</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Try again
</button>
</div>
);
}Step 12: Measure Performance
Run a production build to see the PPR optimization in action:
npm run buildIn the build output, you will see symbols next to each route:
Route (app) Size First Load JS
┌ ◐ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
└ ƒ /api/webhook 0 B 0 B
○ (Static) prerendered as static content
◐ (Partial) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
The ◐ symbol indicates PPR is active on that route. The static parts are prerendered, and dynamic parts will stream.
Comparing Performance
Start the production server and measure:
npm run startOpen Chrome DevTools, go to the Performance tab, and record a page load. You should observe:
- FCP (First Contentful Paint): Near-instant, as the static shell comes from cache
- LCP (Largest Contentful Paint): Depends on which element is largest — if static, it is instant; if dynamic, it is when that Suspense boundary resolves
- CLS (Cumulative Layout Shift): Near-zero if your skeletons match the final layout dimensions
- TTFB (Time to First Byte): Dramatically lower than full SSR
Step 13: Deploy to Vercel
PPR works out of the box on Vercel, which caches the static shell at the edge:
npm install -g vercel
vercelAfter deployment, you can verify PPR is working by:
- Opening DevTools Network tab
- Loading the page
- Observing the initial HTML response contains the static shell with skeleton fallbacks
- Watching subsequent streamed chunks fill in the dynamic content
On Vercel, the static shell is served from the edge CDN closest to the user, while dynamic holes are computed at the nearest serverless function region.
PPR Best Practices
Do: Place Suspense Boundaries Strategically
Each Suspense boundary is a streaming entry point. Place them around logical UI sections, not individual elements:
// Good: One boundary per logical section
<Suspense fallback={<MetricsSkeleton />}>
<MetricsGrid /> {/* Contains 4 metric cards */}
</Suspense>
// Avoid: Too many tiny boundaries (overhead)
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="Visitors" />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<MetricCard label="Revenue" />
</Suspense>Do: Match Skeleton Dimensions
Ensure your skeleton components have the exact same dimensions as the loaded content. This prevents layout shift:
// Good: Skeleton matches final height
<div className="h-64 rounded-xl animate-pulse bg-gray-200" />
// Bad: Skeleton is a different height — causes CLS
<div className="h-32 rounded-xl animate-pulse bg-gray-200" />Do: Keep Static Parts Truly Static
Do not accidentally make static parts dynamic. Common mistakes:
// BAD: Date() makes the entire component dynamic
export default function Footer() {
return <p>Generated at {new Date().toISOString()}</p>;
}
// GOOD: Use connection() inside a Suspense boundary instead
export default function Footer() {
return (
<div>
<p>© 2026 MyApp</p> {/* Static */}
<Suspense fallback={null}>
<ServerTime /> {/* Dynamic, isolated */}
</Suspense>
</div>
);
}Do Not: Nest Dynamic Components Without Suspense
If a dynamic component is not wrapped in Suspense, it makes the entire page dynamic — defeating the purpose of PPR:
// BAD: No Suspense — entire page becomes dynamic
export default function Page() {
return (
<div>
<StaticHeader />
<DynamicContent /> {/* No Suspense! */}
</div>
);
}
// GOOD: Dynamic content isolated in Suspense
export default function Page() {
return (
<div>
<StaticHeader />
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
</div>
);
}Troubleshooting
"Page is fully dynamic despite PPR being enabled"
This usually means a dynamic function is called outside a Suspense boundary. Check for:
cookies()orheaders()called in the page component itself (not in a child wrapped in Suspense)searchParamsdestructured at the page level without Suspense around the consuming component- A
fetch()withcache: "no-store"outside of Suspense
"Suspense fallback never disappears"
This means the async component inside Suspense is failing silently. Wrap it in an error boundary to surface the error:
<ErrorBoundary fallback={<p>Error loading data</p>}>
<Suspense fallback={<Skeleton />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>"Layout shift when dynamic content loads"
Your skeleton does not match the dimensions of the final content. Measure the rendered component and update your skeleton to match.
Build output does not show ◐ symbol
Make sure experimental.ppr is set to true in next.config.ts and that you have at least one <Suspense> boundary in the page.
Next Steps
Now that you have built a PPR-powered dashboard, here are ways to extend it:
- Add real data sources: Replace the simulated data with database queries using Drizzle or Prisma
- Implement authentication: Use
cookies()inside a Suspense boundary to show user-specific data - Add client interactivity: Combine PPR with Client Components for charts using libraries like Recharts or Chart.js
- Implement ISR hybrid: Use
revalidateon certain static sections for periodic refresh without full dynamic rendering - Monitor in production: Use Vercel Analytics or OpenTelemetry to track real-world PPR performance
Conclusion
Partial Prerendering in Next.js 15 eliminates the oldest trade-off in web development: choosing between fast static pages and dynamic personalized content. With PPR, you get both — a CDN-cached static shell that loads instantly, with dynamic content streaming in progressively.
The key concepts to remember:
- Everything is static by default — dynamic behavior is opt-in
- Suspense boundaries define the static/dynamic split — they are your rendering architecture
- Each dynamic hole resolves independently — slow queries do not block fast ones
- Skeletons are part of the static shell — they are prerendered and cached, giving users immediate visual feedback
PPR is not just a performance optimization — it is a fundamentally better architecture for building web applications. Start using it today in your Next.js 15 projects.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.