DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Next.js Partial Prerendering Guide: Migrate One Page Fast

Why partial prerendering? The high-ROI trick

Next.js partial prerendering (PPR) gives you the best of static and dynamic rendering: an edge-cached static shell paints instantly while only the per-request pieces stream in. For many apps this single change yields outsized wins to Largest Contentful Paint (LCP) and Time to First Byte (TTFB) because the hero content arrives from the CDN before any slow backend work runs.

This article walks a pragmatic, low-risk path: pick one slow, high-traffic page and migrate it to a static shell with streamed dynamic holes. You’ll learn App Router patterns—Server Actions, Suspense streaming, and the new caching model—without rewriting the whole site.


When to pick this page

Choose a page with:

  • High traffic and business importance (homepage, product detail, feed)
  • A clear above-the-fold element that can live in the static shell (hero title, image)
  • A small set of per-request pieces (comments, recommendations, cart count) that can be isolated

If those conditions hold, PPR often delivers the biggest end-to-end LCP win for the least engineering effort.


Migration checklist (step-by-step)

  1. Identify the slowest, highest-traffic page (use RUM or analytics).
  2. Convert the route to an App Router server component (app/…/page.tsx).
  3. Add the caching directive to the shell: "use cache" on functions or files you want cached.
  4. Replace internal API POSTs with Server Actions ('use server') to avoid an extra client→/api roundtrip.
  5. Wrap per-request UI in Suspense boundaries and stream them as small server components.
  6. Give every Suspense fallback the right dimensions (skeletons) to avoid CLS.
  7. Measure median LCP before and after in lab (Lighthouse) and field (RUM / Core Web Vitals).
  8. Share a clear percent improvement with stakeholders and roll out further if results are positive.

Quick concrete example

Below is a simplified App Router page that ships a cacheable shell and streams comments as a dynamic hole. Note the "use cache" directive at the top of the cached module and a small PostComments Server Component inside a Suspense boundary.

// app/posts/[id]/page.tsx
import React, { Suspense } from 'react';
import PostComments from './PostComments';

// Keep the shell cacheable
"use cache";

export default async function Page({ params }: { params: { id: string } }) {
  // cached fetch: part of the shell
  const post = await fetch(`https://api.example.com/posts/${params.id}`, {
    next: { revalidate: 60 }
  }).then(r => r.json());

  return (
    <main>
      <article>
        <h1>{post.title}</h1>
        <p>{post.summary}</p>
      </article>

      <Suspense fallback={<CommentsSkeleton />}>
        {/* dynamic, per-request hole --> streamed */}
        <PostComments postId={params.id} />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

And replace API route POSTs with a Server Action so the client calls the action directly instead of hitting /api.

// app/actions/addComment.ts
export async function addComment(formData: FormData) {
  'use server';
  const postId = formData.get('postId')?.toString();
  const content = formData.get('content')?.toString();

  if (!postId || !content) throw new Error('invalid');

  await db.createComment({ postId, content });
  revalidatePath(`/posts/${postId}`); // refresh cached shell or tags
}
Enter fullscreen mode Exit fullscreen mode

Why this helps: the browser avoids an extra roundtrip to /api and the Server Action can call revalidatePath or revalidateTag immediately after the mutation.


Important implementation notes and gotchas

  • "use cache" is explicit: in the Cache Components model nothing is cached unless you opt in. Mark the functions/files that should be part of the static shell.
  • Keep request-bound reads (cookies(), headers(), searchParams) inside Suspense boundaries. If they appear at the top-level layout you’ll force the whole route to become dynamic.
  • Prevent CLS: design skeleton fallbacks with the same dimensions as the final content (image aspect ratios, text line heights).
  • Granularity: push Suspense as deep as possible. Small streamed holes mean the shell stays larger and arrives faster.
  • Invalidate intelligently: use cacheTag / revalidateTag for on-demand updates (CMS webhooks, mutations), and cacheLife / revalidate for TTL-based freshness.

Measurement strategy (lab + field)

  1. Baseline: capture median LCP (mobile, slow 3G emulation) with Lighthouse and collect p50/p75 LCP from your RUM dataset (CrUX or your Web Vitals pipeline).
  2. Deploy the PPR change behind a feature flag or to a percentage of traffic.
  3. Repeat Lighthouse lab runs and gather RUM data for a comparable window (48–72 hours) to smooth noise.
  4. Report both lab and field: e.g., median LCP dropped from ~3.2s → ~1.1s (lab) and p75 RUM improved by X%.

Tools and queries to use:

  • Lighthouse (CLI): lighthouse https://example.com/posts/123 --only-categories=performance
  • Web Vitals RUM: the web-vitals library or your existing RUM provider's LCP metric
  • CrUX (if available) for public pages

A conservative, repeatable migration I helped run cut median LCP from ~3.2s to ~1.1s on a busy product page — an easy ROI to show stakeholders.


Rollout plan and next steps

  • Start with one page and validate the LCP delta.
  • Iterate on Suspense boundaries and skeletons to minimize CLS and tail latency in the streamed holes.
  • Replace more API routes with Server Actions where appropriate (mutations, most internal endpoints).
  • Use cacheTag and revalidateTag for precise invalidation after writes.

If you can ship a static shell and stream a tiny Server Component in a single PR, you’ve proven the pattern. That one success is usually enough to justify a broader rollout.

Which single page on your site would you pick to experiment with Next.js partial prerendering first?

Top comments (0)