Marketplace

nextjs-reviewer

Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration. Ensure adherence to Next.js 16+ Cache Components best practices.

$ Instalar

git clone https://github.com/jaredpalmer/claude-plugins /tmp/claude-plugins && cp -r /tmp/claude-plugins/skills/nextjs-reviewer ~/.claude/skills/claude-plugins

// tip: Run this command in your terminal to install the skill


name: nextjs-reviewer description: Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration. Ensure adherence to Next.js 16+ Cache Components best practices.

Next.js PPR & Caching Code Review Skill

Purpose

Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration. Ensure adherence to Next.js 16+ Cache Components best practices.

Documentation Version: Based on Next.js 16.0.4 official documentation Last Updated: 2025-11-25 Source: https://nextjs.org/docs/app/getting-started/partial-prerendering

When to Use

  • Before creating pull requests with Next.js components
  • When implementing new data-fetching features
  • During performance optimization reviews
  • When adding or modifying Suspense boundaries
  • After implementing caching strategies

Prerequisites

  • Next.js 16+ with cacheComponents: true in next.config
  • App Router (not Pages Router)
  • Understanding of Server Components vs Client Components

Understanding the Two Cache Systems

๐Ÿ“– Reference: Cache Components - With runtime data

Before reviewing code, understand these two completely different caching mechanisms:

ConceptReact cache()'use cache' directive
Importimport { cache } from 'react'Directive: 'use cache'
ScopeSame-REQUEST deduplicationCross-REQUEST caching
DurationSingle render pass onlyMinutes / hours / days
Use CasegetCurrentUser() called 5x = 1 actual callData cached for all users
Works with cookies()โœ… Yes (wraps the function)โŒ No (use 'use cache: private')

The Architecture Layers

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ LAYER 1: Layout/Page (STATIC SHELL)                                     โ”‚
โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
โ”‚ โ€ข NO cookies(), NO headers(), NO runtime data                           โ”‚
โ”‚ โ€ข Prerendered at build time โ†’ instant delivery                          โ”‚
โ”‚ โ€ข Contains <Suspense> boundaries as deep as possible                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                    โ”‚
                                    โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ LAYER 2: Auth Boundary (DYNAMIC - inside Suspense)                      โ”‚
โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
โ”‚ โ€ข Calls cookies() to get session token                                  โ”‚
โ”‚ โ€ข Uses getCurrentUser() wrapped with React cache() for dedup            โ”‚
โ”‚ โ€ข Handles redirect('/login') if not authenticated                       โ”‚
โ”‚ โ€ข Passes accessToken DOWN to cached components as prop                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                    โ”‚
                                    โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ LAYER 3: Cached Data (CACHED - 'use cache' with token as key)           โ”‚
โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
โ”‚ โ€ข Receives accessToken as PROP (automatically becomes cache key)        โ”‚
โ”‚ โ€ข Uses 'use cache' + cacheLife() + cacheTag()                           โ”‚
โ”‚ โ€ข Fetches user-specific data using the token                            โ”‚
โ”‚ โ€ข Cached PER-USER across multiple requests                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Complete Auth + Caching Implementation

Step 1: Auth Utilities (auth/server.ts)

import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

// Internal: Read session from cookie (CANNOT be cached - runtime data)
async function getSessionFromCookie() {
  const cookieStore = await cookies();
  const session = cookieStore.get('github_session')?.value;
  return session ? decrypt(session) : null;
}

// โœ… Wrapped with React cache() for SAME-REQUEST deduplication
// If layout + page + 10 components call this = 1 actual cookie read
export const getCurrentUser = cache(async () => {
  const session = await getSessionFromCookie();
  if (!session) return null;
  return {
    accessToken: session.githubToken,
    userId: session.githubId,
    userName: session.userName,
  };
});

// โœ… Auth guard - redirects if not logged in
export async function requireAuth() {
  const user = await getCurrentUser();
  if (!user) {
    redirect('/login');
  }
  return user;
}

Step 2: Layout (STATIC SHELL - no runtime data)

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  // โš ๏ธ NO cookies() here! Layout stays in static shell.
  return (
    <html>
      <body>
        <StaticHeader />   {/* โœ… Part of static shell */}
        <StaticSidebar />  {/* โœ… Part of static shell */}
        {children}
        <StaticFooter />   {/* โœ… Part of static shell */}
      </body>
    </html>
  );
}

Step 3: Page with Suspense Boundaries (as deep as possible)

// app/pulls/page.tsx
import { Suspense } from 'react';

export default function PullsPage() {
  // โœ… Page itself is STATIC - no runtime data access here
  return (
    <div>
      <h1>Pull Requests</h1>  {/* โœ… Static shell */}

      {/* โœ… Suspense boundary as DEEP as possible */}
      <Suspense fallback={<PullsSkeleton />}>
        <AuthenticatedPullsList />
      </Suspense>
    </div>
  );
}

Step 4: Auth Boundary Component (DYNAMIC)

// components/authenticated-pulls-list.tsx
import { requireAuth } from '@/auth/server';

// โš ๏ธ This component is DYNAMIC - accesses cookies via requireAuth
// โš ๏ธ MUST be wrapped in <Suspense> at usage site
export async function AuthenticatedPullsList() {
  // Step 1: Auth check (reads cookies, may redirect)
  const user = await requireAuth();

  // Step 2: Pass token to CACHED component (token = cache key)
  return <PullsListCached accessToken={user.accessToken} />;
}

Step 5: Cached Data Component

// components/pulls-list-cached.tsx
import { cacheLife, cacheTag } from 'next/cache';

// โœ… This component is CACHED across requests
// โœ… accessToken is part of cache key - each user gets own cache
async function PullsListCached({ accessToken }: { accessToken: string }) {
  'use cache';
  cacheLife('minutes');        // 5 min stale, 1 min revalidate
  cacheTag('user-pulls');      // For on-demand invalidation

  // This fetch is cached per-user (keyed by accessToken prop)
  const client = createGitHubClient(accessToken);
  const pulls = await client.pulls.list();

  return (
    <ul>
      {pulls.map(pr => <PullRequestItem key={pr.id} pr={pr} />)}
    </ul>
  );
}

Why This Pattern is Optimal

BenefitHow It's Achieved
Maximum static shellLayout, headers, titles prerendered instantly
Suspense as deep as possibleOnly data sections stream; everything else instant
No duplicate cookie readsgetCurrentUser() with React cache() = 1 read per request
Cross-request caching'use cache' with token key = per-user cache reuse
Cache isolationToken as prop = automatic per-user cache keys

Critical Insight: Where Auth Happens

// โŒ WRONG - Auth in layout blocks entire layout from prerendering
export default async function Layout({ children }) {
  const user = await getCurrentUser(); // cookies() blocks prerender!
  return <div>{children}</div>;
}

// โœ… CORRECT - Layout is static, auth is inside page's Suspense
export default function Layout({ children }) {
  return (
    <div>
      <StaticNav />
      {children}  {/* Pages put auth inside their own Suspense */}
    </div>
  );
}

Decision Matrix: Which Cache to Use

What You're DoingWhich CacheWhy
getCurrentUser() - reading cookiesReact cache()Same-request dedup; can't cache cookies cross-request
getGitHubClient(token) - creating clientReact cache()Same-request dedup; reuse client instance
fetchUserRepos(token) - API call with token'use cache'Cross-request cache; token is cache key
fetchPublicRepo(owner, repo) - public data'use cache'Cross-request cache; no auth needed
fetchUserDashboard() - needs cookies directly'use cache: private'Cross-request with cookie access

Review Checklist

1. PPR Pattern Implementation

๐Ÿ“– Reference: Cache Components

The Core Concept:

Cache Components lets you mix static, cached, and dynamic content in a single route:

Content TypeWhen UsedHow to Handle
StaticSynchronous I/O, pure computationsAuto-prerendered into static shell
CachedDynamic data without runtime contextUse 'use cache' directive
DynamicNeeds cookies, headers, searchParamsWrap in <Suspense> boundaries

โœ… CORRECT Pattern (Public/Shared Data):

// Outer component - accesses runtime data (stays dynamic)
export async function DataSection() {
  const user = await getCurrentUser(); // accesses cookies
  if (!user?.accessToken) redirect('/login');

  return <DataSectionCached accessToken={user.accessToken} />;
}

// Inner component - cached with 'use cache'
async function DataSectionCached({ accessToken }: { accessToken: string }) {
  'use cache';
  cacheLife('minutes');

  const client = getCachedAuthenticatedClient(accessToken);
  const data = await fetchData(client);
  return <UI data={data} />;
}

// โš ๏ธ CRITICAL: Usage site MUST wrap in Suspense
// app/page.tsx
export default function Page() {
  return (
    <Suspense fallback={<DataSkeleton />}>
      <DataSection />
    </Suspense>
  );
}

โŒ INCORRECT Pattern:

// โŒ Auth check blocks everything from being cached
export async function DataSection() {
  const user = await getCurrentUser(); // accesses cookies - blocks caching
  const client = getCachedAuthenticatedClient(user.accessToken);
  const data = await fetchData(client); // this could be cached but isn't
  return <UI data={data} />;
}

Check for:

  • Runtime data access (cookies, headers, searchParams) isolated in outer wrapper
  • Data fetching moved to inner cached component
  • 'use cache' directive at top of cached function/component
  • cacheLife() called with appropriate duration
  • Cache key includes all varying parameters (passed as props)
  • Outer component wrapped in <Suspense> at usage site

1.5 PPR Pattern with Personalized Data (use cache: private)

๐Ÿ“– Reference: use cache: private directive

When to Use: For user-specific data where each user needs their own cache entry (dashboards, feeds, personalized recommendations).

โœ… CORRECT Pattern:

import { cookies } from 'next/headers';
import { cacheLife, cacheTag } from 'next/cache';
import { Suspense } from 'react';

// Usage - MUST wrap in Suspense (not prerendered)
export default function Page() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <UserDashboard />
    </Suspense>
  );
}

// Single function - no split needed with private cache!
async function UserDashboard() {
  'use cache: private';
  cacheLife({ stale: 60 }); // Minimum 30s required for runtime prefetch

  // Can access cookies directly
  const session = await cookies();
  const userId = session.get('userId')?.value;

  const data = await fetchUserSpecificData(userId);
  return <Dashboard data={data} />;
}

Real-World Example (GitHub-style):

// User's personalized pull request dashboard
async function MyPullsPage() {
  'use cache: private';
  cacheLife('minutes'); // 5 min stale, 1 min revalidate

  const session = await cookies();
  const userId = session.get('userId')?.value;

  const myPrs = await db.pulls.findMany({
    where: {
      OR: [
        { authorId: userId },
        { assignees: { some: { id: userId } } },
      ],
    },
  });

  return <DashboardTable items={myPrs} />;
}

Comparison: Public vs Private Caching

Feature'use cache' (Public)'use cache: private' (Private)
Use CaseShared across all usersPer-user personalized data
Example/vercel/next.js/issues/pulls, /dashboard
Can access cookies()โŒ Noโœ… Yes
Can access headers()โŒ Noโœ… Yes
Can use searchParams propโœ… Yes (as prop)โœ… Yes (as prop or via access)
Can access connection()โŒ NoโŒ No
Prerendered in static shellโœ… YesโŒ No (personalized)
Minimum stale time30 seconds30 seconds
Cache scopeGlobal (all users share)Per-user (isolated)

Caching Strategy Decision Matrix

Page TypeExample RouteDirectiveRevalidation Strategy
Public Static/about, Marketing'use cache'cacheLife('weeks') or 'days'
Public Dynamic/vercel/next.js/issues'use cache'cacheTag('repo-issues')
User Private/pulls, /dashboard'use cache: private'cacheLife('minutes') + tags
Real-timeComments, live feedNo directive<Suspense> + streaming

Check for:

  • Personalized data uses 'use cache: private'
  • Private caches have cacheLife with stale >= 30 seconds
  • Public shared data uses standard 'use cache'
  • Private cache components wrapped in <Suspense> at usage site
  • connection() NOT used inside any cache directive

1.6 Async Dynamic APIs (Breaking Change)

๐Ÿ“– Reference: page.js - params and searchParams

Next.js 15+ Breaking Change: params and searchParams are now Promises and must be awaited.

โŒ WRONG (Next.js 14 and earlier - no longer works):

// This will cause runtime errors in Next.js 15+
export default function Page({ params }: { params: { slug: string } }) {
  const slug = params.slug; // โŒ ERROR: params is a Promise
  return <h1>{slug}</h1>;
}

โœ… CORRECT (Next.js 15+):

// Server Component - use async/await
export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const { slug } = await params;
  const { query } = await searchParams;
  return <h1>{slug} - {query}</h1>;
}

// Client Component - use React's use() hook
'use client';
import { use } from 'react';

export default function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const { slug } = use(params);
  const { query } = use(searchParams);
  return <h1>{slug} - {query}</h1>;
}

TypeScript Helper (Next.js 16+):

// Use PageProps helper for automatic typing from route literal
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params;
  const query = await props.searchParams;
  return <h1>Blog Post: {slug}</h1>;
}

โš ๏ธ PPR Impact: Accessing searchParams triggers dynamic rendering. Always wrap components that access searchParams in <Suspense> boundaries to maximize the static shell.

Check for:

  • All params accesses use await (Server Components) or use() (Client Components)
  • All searchParams accesses use await or use()
  • TypeScript types show Promise<...> not plain objects
  • Components accessing searchParams are wrapped in <Suspense>
  • Consider using PageProps<'/route/[param]'> helper for type safety

1.7 Proxy File Convention (Replaces middleware.ts)

๐Ÿ“– Reference: proxy.js

Next.js 16 Change: middleware.ts is now proxy.ts. A codemod is available:

npx @next/codemod@latest middleware-to-proxy .

Key Differences:

Featuremiddleware.ts (deprecated)proxy.ts (Next.js 16+)
RuntimeEdge RuntimeNode.js Runtime
LocationProject root or src/Project root or src/
PurposeRequest interceptionRequest interception + full Node.js APIs
CapabilitiesLimited Edge APIsFull Node.js APIs, DB access

Example proxy.ts:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  // Now runs on Node.js runtime - full access to Node APIs
  const response = NextResponse.next();

  // Authentication, logging, redirects, etc.
  if (!request.cookies.get('session')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return response;
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Check for:

  • Project uses proxy.ts instead of deprecated middleware.ts
  • matcher config excludes metadata files if needed
  • Proxy logic is modular (split into separate files, imported into proxy.ts)

2. Cache Strategy

๐Ÿ“– Reference: cacheLife() function

Preset Cache Profiles (ACCURATE VALUES):

ProfilestalerevalidateexpireUse Case
default5 min15 min1 yearStandard content
seconds30 sec1 sec1 minReal-time data (aggressive!)
minutes5 min1 min1 hourFrequently updated
hours5 min1 hour1 dayMultiple daily updates
days5 min1 day1 weekDaily updates
weeks5 min1 week30 daysWeekly updates
max5 min30 days1 yearRarely changes

โš ๏ธ Note: All profiles have 5 min stale time (except seconds at 30s). The revalidate time is what varies significantly between profiles.

Usage Examples:

// Frequently changing data (user activity, notifications)
'use cache';
cacheLife('minutes'); // 5 min stale, 1 min revalidate, 1 hour expire

// Moderate change frequency (user repos, profile data)
'use cache';
cacheLife('hours'); // 5 min stale, 1 hour revalidate, 1 day expire

// Rarely changing data (static content, config)
'use cache';
cacheLife('days'); // 5 min stale, 1 day revalidate, 1 week expire

// Custom inline profile
'use cache';
cacheLife({
  stale: 3600,      // 1 hour
  revalidate: 900,  // 15 minutes
  expire: 86400,    // 1 day
});

Check for:

  • cacheLife() matches data freshness requirements
  • Understand 'seconds' profile is very aggressive (1s revalidate)
  • High-frequency data uses 'minutes' (1 min revalidate)
  • Low-frequency data uses 'hours'/'days'
  • Cache tags used with cacheTag() for on-demand revalidation

3. Suspense Boundary Placement

๐Ÿ“– Reference: Cache Components - Defer rendering to request time

โœ… CORRECT - Deep Suspense boundaries:

export default function Page() {
  return (
    <div>
      <StaticHeader /> {/* Part of static shell */}
      <Suspense fallback={<PullsSkeleton />}>
        <PullRequestsSection /> {/* Streams independently */}
      </Suspense>
      <Suspense fallback={<IssuesSkeleton />}>
        <IssuesSection /> {/* Streams independently */}
      </Suspense>
      <StaticFooter /> {/* Part of static shell */}
    </div>
  );
}

โŒ INCORRECT - Shallow Suspense (blocks too much):

export default function Page() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      <StaticHeader />  {/* Unnecessarily blocked! */}
      <PullRequestsSection />
      <IssuesSection />
      <StaticFooter />  {/* Unnecessarily blocked! */}
    </Suspense>
  );
}

Check for:

  • Suspense boundaries at deepest necessary points
  • Static content outside Suspense (part of static shell)
  • Each independent async section has its own Suspense
  • Suspense key prop used when data depends on params: key={query || 'default'}
  • Meaningful loading skeletons provided

4. React Query Integration Strategy

๐Ÿ“– Note: React Query patterns are framework-agnostic. Next.js does not have official React Query docs - refer to TanStack Query Documentation.

Decision Tree:

โ”Œโ”€ Server Component?
โ”‚  โ”œโ”€ Yes โ†’ Use 'use cache' + cacheLife (NOT React Query)
โ”‚  โ”‚
โ”‚  โ””โ”€ No (Client Component) โ†’
โ”‚     โ”‚
โ”‚     โ”œโ”€ Need SSR data? โ†’ prefetchQuery + HydrationBoundary
โ”‚     โ”‚
โ”‚     โ””โ”€ Client-only? โ†’ Standard useSuspenseQuery

Server Components: Use 'use cache' (NOT React Query)

// โœ… Server Components - Native Next.js caching
async function ServerData() {
  'use cache';
  cacheLife('hours');
  const data = await fetch('/api/data');
  return <UI data={data} />;
}

Client Components with SSR: Prefetch + Hydration Pattern

// Server wrapper
import { getQueryClient } from '@/app/get-query-client';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';

async function DataWrapper({ userId }: { userId: string }) {
  const queryClient = getQueryClient();

  // โš ๏ธ CRITICAL: Don't await! Fire and forget.
  queryClient.prefetchQuery({
    queryKey: ['data', userId],
    queryFn: () => fetchData(userId),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DataClient userId={userId} />
    </HydrationBoundary>
  );
}
// Client consumer
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';

export function DataClient({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['data', userId],
    queryFn: () => fetchData(userId),
  });

  return <UI data={data} />;
}

Query Client Configuration:

// app/get-query-client.ts
import {
  QueryClient,
  defaultShouldDehydrateQuery,
  isServer,
} from '@tanstack/react-query';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // Prevents refetch after hydration
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending', // Include pending for PPR
        shouldRedactErrors: () => false,
      },
    },
  });
}

let browserQueryClient: QueryClient | undefined;

export function getQueryClient() {
  if (isServer) {
    return makeQueryClient(); // Always new on server
  }
  if (!browserQueryClient) {
    browserQueryClient = makeQueryClient(); // Singleton on client
  }
  return browserQueryClient;
}

Check for:

  • Server Components use 'use cache' (NOT React Query)
  • prefetchQuery called WITHOUT await
  • HydrationBoundary wraps client components
  • useSuspenseQuery used (not useQuery)
  • staleTime: 60000 configured to prevent refetch
  • shouldDehydrateQuery includes pending queries
  • Browser QueryClient is singleton; Server is per-request

5. Request Deduplication

๐Ÿ“– Reference: React cache() for request memoization

React's cache() wrapper:

import { cache } from 'react';

// โœ… Wrap fetchers with cache() for same-request deduplication
const getUserUncached = async (client: Client) => {
  const { data } = await usersGetAuthenticated({ client });
  return data;
};

export const getUser = cache(getUserUncached);

Check for:

  • All data fetchers wrapped with React's cache()
  • Cache wraps the implementation, not exported directly
  • Used for same-request deduplication (layout + page + components)
  • Works alongside 'use cache' (different purposes)
  • Auth helpers like getCurrentUser() wrapped with cache()

6. Common Anti-Patterns

๐Ÿ“– Reference: use cache - Constraints

โŒ Avoid these patterns:

// โŒ Using cookies() inside 'use cache' scope
async function BadCached() {
  'use cache';
  const cookieStore = await cookies(); // ERROR: Can't access runtime data
  return <div />;
}

// โœ… FIX OPTION 1: Use 'use cache: private' for personalized data
async function GoodCachedPrivate() {
  'use cache: private';
  cacheLife({ stale: 60 }); // Min 30s for prefetch

  const cookieStore = await cookies();
  const userId = cookieStore.get('userId')?.value;
  return <div>{userId}</div>;
}

// โœ… FIX OPTION 2: Split outer/inner for public shared data
export async function GoodCachedPublic() {
  const user = await getCurrentUser();
  return <CachedComponent userId={user.id} />;
}

async function CachedComponent({ userId }: { userId: string }) {
  'use cache';
  const data = await fetchData(userId);
  return <div>{data}</div>;
}

// โŒ Using connection() in any cache directive
async function BadConnection() {
  'use cache: private';
  await connection(); // ERROR: connection() not allowed in ANY cache directive
  return <div />;
}

// โŒ Shallow Suspense blocking static content
<Suspense fallback={<LoadingPage />}>
  <Header />           {/* Static but blocked! */}
  <DynamicContent />
</Suspense>

// โœ… FIX: Move static content outside
<Header />
<Suspense fallback={<LoadingContent />}>
  <DynamicContent />
</Suspense>

Cache Key Behavior:

๐Ÿ“– Reference: use cache - Cache keys

With 'use cache', cache keys automatically include:

  1. Build ID - Unique per build
  2. Function ID - Secure hash of function location and signature
  3. Serializable arguments - Props (for components) or function arguments
  4. HMR refresh hash (development only)

Closed-over values from parent scopes are automatically captured. You don't need to manually configure cache keys - just pass all varying parameters as props.

Check for:

  • No cookies()/headers() inside 'use cache' (use 'use cache: private' instead)
  • No connection() in ANY cache directive
  • Auth checks separated from cached data (for public data patterns)
  • searchParams accessed inside Suspense boundaries
  • No shallow Suspense blocking static content
  • All varying parameters passed as props

7. Build Output Verification

After implementing PPR, verify in build output:

bun run build

Expected output:

Route (app)
โ”Œ โ— /                    (Partial Prerender) โœ…
โ”œ โ— /dashboard           (Partial Prerender) โœ…
โ”” โ—‹ /static              (Static)            โœ…

Symbols:

  • โ— = Partial Prerender (PPR) - GOAL for dynamic pages
  • โ—‹ = Static - Good for truly static pages
  • ฦ’ = Dynamic - Should be rare with PPR

Check for:

  • Dynamic pages show โ— symbol
  • No unexpected ฦ’ (fully dynamic) routes
  • API routes correctly marked as ฦ’
  • Build completes without "Uncached data" errors

8. Next.js MCP Runtime Validation

Use Next.js MCP tools to check for runtime issues:

// 1. Discover running Next.js servers
mcp__next-devtools__nextjs_index()

// 2. Check for errors (use port from step 1)
mcp__next-devtools__nextjs_call({
  port: "3000",
  toolName: "get_errors"
})

// 3. Get route information
mcp__next-devtools__nextjs_call({
  port: "3000",
  toolName: "get_routes"
})

Check for:

  • No runtime errors in browser sessions
  • No "Uncached data accessed outside Suspense" errors
  • No "cookies() during prerender" errors
  • Routes properly registered

Advanced Patterns

9. Cache Invalidation

๐Ÿ“– References:

Two Invalidation Strategies:

FunctionWhereBehaviorUse Case
updateTag(tag)Server Actions onlyImmediate - next request waits for fresh dataRead-your-own-writes
revalidateTag(tag, profile)Server Actions + Route HandlersStale-while-revalidate - serves cached while fetchingBackground refresh

updateTag - Immediate invalidation (read-your-own-writes):

import { cacheTag, updateTag } from 'next/cache';

// Component
async function Posts() {
  'use cache';
  cacheTag('posts');
  const posts = await fetchPosts();
  return <PostList posts={posts} />;
}

// Server Action - User sees their changes immediately
async function createPost(data: FormData) {
  'use server';
  await db.posts.create(data);
  updateTag('posts'); // Next request waits for fresh data
}

revalidateTag - Stale-while-revalidate:

import { revalidateTag } from 'next/cache';

// Server Action OR Route Handler
async function refreshPosts() {
  'use server';
  await db.posts.create(data);
  revalidateTag('posts', 'max'); // โš ๏ธ Second argument REQUIRED
}

โš ๏ธ BREAKING CHANGE: revalidateTag(tag) without second argument is deprecated. Always use revalidateTag(tag, 'max') or specify a cache profile.


10. Optimistic Updates with useOptimistic

๐Ÿ“– Reference: React useOptimistic hook

'use client';
import { useOptimistic, useTransition } from 'react';
import { useMutation } from '@tanstack/react-query';

export function MessageList({ messages }: { messages: Message[] }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMsg: Message) => [...state, newMsg]
  );

  const sendMutation = useMutation({
    mutationFn: (text: string) => api.sendMessage(text),
  });

  const handleSend = (text: string) => {
    const optimistic: Message = {
      id: `temp-${Date.now()}`,
      text,
      isPending: true,
    };

    startTransition(async () => {
      addOptimistic(optimistic);
      await sendMutation.mutateAsync(text);
    });
  };

  return (
    <ul>
      {optimisticMessages.map((msg) => (
        <li key={msg.id} className={msg.isPending ? 'opacity-50' : ''}>
          {msg.text}
        </li>
      ))}
    </ul>
  );
}

Check for:

  • useOptimistic used for pending state
  • useTransition wraps async mutation
  • Optimistic items have temporary IDs
  • Visual indicator for pending state
  • Auto-rollback on error (built-in)

Common Fixes

Fix 1: Split Auth from Data Fetching

Before:

export async function Component() {
  const user = await getCurrentUser();
  const data = await fetchData(user.accessToken);
  return <UI data={data} />;
}

After:

export async function Component() {
  const user = await getCurrentUser();
  return <ComponentCached accessToken={user.accessToken} />;
}

async function ComponentCached({ accessToken }: { accessToken: string }) {
  'use cache';
  cacheLife('minutes');
  const data = await fetchData(accessToken);
  return <UI data={data} />;
}

Fix 2: Deep Suspense Boundaries

Before:

<Suspense fallback={<FullPageLoader />}>
  <Header />
  <Content />
  <Footer />
</Suspense>

After:

<Header />
<Suspense fallback={<ContentLoader />}>
  <Content />
</Suspense>
<Footer />

Fix 3: Add Cache to Data Fetching

Before:

async function Component() {
  const data = await fetch('/api/data');
  return <UI data={data} />;
}

After:

async function Component({ userId }: { userId: string }) {
  'use cache';
  cacheLife('hours');
  cacheTag(`user-${userId}-data`);
  const data = await fetch(`/api/data?user=${userId}`);
  return <UI data={data} />;
}

Performance Metrics

After implementing PPR with proper caching:

Expected improvements:

  • โœ… Time to First Byte (TTFB): < 200ms (static shell)
  • โœ… First Contentful Paint (FCP): < 1s (static shell visible)
  • โœ… Largest Contentful Paint (LCP): < 2.5s (with streaming)
  • โœ… Reduced API calls: 50-90% reduction via caching
  • โœ… Lower server load: Cached responses served without DB/API hits

Monitor:

  • Build output for route types (โ— vs โ—‹ vs ฦ’)
  • Runtime errors via Next.js MCP
  • Cache hit rates in production
  • API rate limit usage (should decrease)

Documentation References

๐Ÿ“š CRITICAL: All patterns in this skill are based on official Next.js 16.0.4+ documentation.

Core Documentation:

Query via MCP:

mcp__next-devtools__nextjs_docs({
  action: 'get',
  path: '/docs/app/getting-started/partial-prerendering',
})

Summary Checklist

For every PR with data-fetching components:

Core PPR Patterns:

  • Runtime data access (cookies, headers) isolated OR use 'use cache: private'
  • Public shared data uses 'use cache' + cacheLife()
  • Personalized data uses 'use cache: private' + cacheLife() (min 30s stale)
  • connection() NOT used inside any cache directive
  • Cache keys include all varying parameters (as props - automatic)
  • Suspense boundaries at deepest necessary points
  • Static content outside Suspense (part of static shell)
  • Components accessing runtime APIs wrapped in <Suspense> at usage
  • Appropriate cacheLife profiles for data freshness
  • React's cache() used for request deduplication
  • Build output shows โ— for dynamic pages
  • No runtime errors

Breaking Changes (Next.js 15+/16):

  • params and searchParams use await (Server) or use() (Client)
  • TypeScript types show Promise<...> for params/searchParams
  • Project uses proxy.ts instead of deprecated middleware.ts

React Query Integration:

  • Server Components use 'use cache' (NOT React Query)
  • Client SSR uses prefetchQuery + HydrationBoundary
  • prefetchQuery called WITHOUT await
  • useSuspenseQuery used instead of useQuery
  • QueryClient configured with staleTime: 60000

Cache Invalidation:

  • updateTag() for immediate invalidation (Server Actions only)
  • revalidateTag(tag, profile) for stale-while-revalidate (always pass profile!)
  • Tags properly applied with cacheTag()

Output Format

When reviewing code, provide:

  1. Summary: Overall PPR readiness (Ready / Needs Work)
  2. Issues Found: List specific anti-patterns with file:line
  3. Recommendations: Concrete fixes with code examples
  4. Build Verification: Check build output for route types
  5. Priority: High/Medium/Low for each issue

Example Output:

## PPR Code Review Summary

**Status:** Needs Work (3 issues found)

### High Priority Issues

1. **Auth check blocking cache** in `components/data-section.tsx:15`
   - Issue: `getCurrentUser()` called inside component that should be cached
   - Fix: Split into outer (dynamic) and inner (cached) components
   - Pattern: See Fix 1 above

2. **Missing cacheLife** in `components/posts.tsx:8`
   - Issue: `'use cache'` without `cacheLife()` call
   - Fix: Add `cacheLife('minutes')` or appropriate profile
   - Impact: Uses default profile (15 min revalidate)

### Medium Priority Issues

3. **Shallow Suspense boundary** in `app/page.tsx:25`
   - Issue: Static header/footer inside Suspense
   - Fix: Move static content outside Suspense
   - Impact: Delays static content unnecessarily

### Build Verification

โœ… Build succeeds
โœ… Routes show โ— (Partial Prerender)
โŒ 3 components need caching improvements

### Recommendations

Priority: Fix 1 first (blocking cache), then Fix 2 (missing cacheLife), then Fix 3 (Suspense).