DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Next.js Partial Prerendering: Build Faster Dashboards

Introduction

Next.js Partial Prerendering (PPR) changes the dashboard performance playbook. Instead of trying to micro-optimize every widget, PPR lets you serve an instant static shell from the edge and stream the slow, per-request pieces into Suspense boundaries. The result: a dramatic drop in perceived load time and measurable improvements to LCP (Largest Contentful Paint).

This article walks through the pattern, why it works, and a concrete engineering example you can apply to internal dashboards and content-heavy pages.

Why the static-shell + streaming model helps

  • Static shell: prerender layout, navigation, headings and any SEO-critical content at build time. The shell can be cached at the CDN edge and delivered with very low TTFB.
  • Dynamic holes: wrap per-request or slow widgets in React Suspense boundaries so the server renders them at request time and streams the results back into the same response.

Perceived performance improves because the user sees UI immediately and can start interacting while widgets hydrate incrementally. Because the LCP element must live in the shell, the metric often improves significantly.

How it fits with Next.js APIs

Key pieces you’ll use:

  • Cache Components (enable in next.config.ts) to opt into the PPR model.
  • Server Components for data fetching (fetch in a Server Component lets the server stream results into Suspense boundaries).
  • React Suspense to mark boundaries that should be streamed per request.
  • Server Actions for mutations (they run on the server and can trigger targeted revalidation).
  • revalidateTag to invalidate only affected widget state or cached shell pieces.

Enable PPR (cacheComponents) in next.config.ts:

// next.config.ts
/**
 * Enable Cache Components (PPR) for App Router projects
 */
export default {
  experimental: {
    cacheComponents: true,
  },
};
Enter fullscreen mode Exit fullscreen mode

Concrete example: widget + shell + mutation

This pattern mirrors a typical dashboard widget workflow.

Server Component (widget) — fetches server-only data and tags the fetch so it can be revalidated:

// app/widgets/Widget.server.tsx
export default async function Widget({ id }: { id: string }) {
  const data = await fetch(`/api/metrics/${id}`, {
    next: { tags: [`widget-${id}`], revalidate: 60 },
  });

  const json = await data.json();
  return <div className="metric">{json.value}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Parent page: render a static Shell and stream widgets using Suspense. Only wrap the dynamic parts.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import Shell from '@/components/Shell';
import Widget from '@/widgets/Widget.server';

export default function Dashboard() {
  return (
    <main>
      <Shell />

      {/* Streamed widget: shell remains instant */}
      <Suspense fallback={<WidgetPlaceholder />}>
        <Widget id="42" />
      </Suspense>

      {/* Add more Suspense-wrapped widgets as needed */}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Action for mutations — update data server-side and target the affected widget with revalidateTag:

// app/actions/updateMetric.ts
'use server'
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';

export async function updateMetric(id: string, payload: any) {
  await db.update(id, payload);
  // Revalidate only the widget tag — avoids full-page rebuilds
  revalidateTag(`widget-${id}`);
}
Enter fullscreen mode Exit fullscreen mode

Why this matters in practice:

  • Wrap only small, truly dynamic components in Suspense so the shell renders instantly.
  • Move fetches into Server Components: server-side fetches can stream and apply tags.
  • Use Server Actions for mutations and call revalidateTag to update only the affected widgets.
  • Keep your LCP element out of Suspense — it should be in the static shell.

Practical checklist before you ship PPR for a dashboard

  1. Identify your LCP element and ensure it is rendered in the static shell (logo, hero card, headline, or hero image).
  2. Move per-user or per-request data fetches into Server Components behind Suspense.
  3. Mark cacheable pieces explicitly with use cache or configure fetch caching as required.
  4. Implement Server Actions for mutations and call revalidateTag for targeted updates.
  5. Add small skeletons as Suspense fallbacks so replacements don't cause layout shift.
  6. Measure LCP and TTFB before and after enabling PPR on one route.

Measuring impact

Start small: enable PPR for a single dashboard route. Record baseline metrics (LCP, TTFB, CLS) for p50/p75. After you enable PPR and deploy, measure the same metrics. Typical results: large TTFB reductions (because the shell is edge-cached) and corresponding LCP wins when hero content is inside the shell.

Real-world notes from teams adopting PPR:

  • Streaming yields the biggest gains when the slow bits are independent widgets; when one slow query blocks the whole page, split it into smaller boundaries.
  • Avoid a single giant Suspense boundary around the whole page — that defeats the purpose and reintroduces slow gating.
  • The build will show errors if request-only APIs are accessed outside Suspense boundaries. Move those calls into streamed components.

When not to use PPR

  • Small apps where everything is static or everything is fully dynamic: traditional SSG or SSR may still be simpler.
  • If migrating from Pages Router is not feasible; PPR requires the App Router and Cache Components model.

Conclusion

Next.js Partial Prerendering is one of the highest-ROI performance changes you can make for dashboards: serve an instant static shell, stream Suspense-wrapped widgets, use Server Actions and revalidateTag for precise updates, and watch perceived performance and LCP improve.

If your team hasn’t tried PPR yet, pick a single dashboard widget that’s slow but isolated (e.g., an activity feed or a metrics card), enable PPR for that route, and measure LCP before and after.

Which dashboard widget would you enable PPR for first in your product, and why? Share your choice and results — I’d love to hear what LCP improvements you see.

Top comments (0)