Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    jaredpalmer

    nextjs-reviewer

    jaredpalmer/nextjs-reviewer
    Coding
    5
    6 installs

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

    Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration...

    SKILL.md

    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:

    Concept React cache() 'use cache' directive
    Import import { cache } from 'react' Directive: 'use cache'
    Scope Same-REQUEST deduplication Cross-REQUEST caching
    Duration Single render pass only Minutes / hours / days
    Use Case getCurrentUser() called 5x = 1 actual call Data 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

    Benefit How It's Achieved
    Maximum static shell Layout, headers, titles prerendered instantly
    Suspense as deep as possible Only data sections stream; everything else instant
    No duplicate cookie reads getCurrentUser() with React cache() = 1 read per request
    Cross-request caching 'use cache' with token key = per-user cache reuse
    Cache isolation Token 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 Doing Which Cache Why
    getCurrentUser() - reading cookies React cache() Same-request dedup; can't cache cookies cross-request
    getGitHubClient(token) - creating client React 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 Type When Used How to Handle
    Static Synchronous I/O, pure computations Auto-prerendered into static shell
    Cached Dynamic data without runtime context Use 'use cache' directive
    Dynamic Needs cookies, headers, searchParams Wrap 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 Case Shared across all users Per-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 time 30 seconds 30 seconds
    Cache scope Global (all users share) Per-user (isolated)

    Caching Strategy Decision Matrix

    Page Type Example Route Directive Revalidation 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-time Comments, live feed No 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:

    Feature middleware.ts (deprecated) proxy.ts (Next.js 16+)
    Runtime Edge Runtime Node.js Runtime
    Location Project root or src/ Project root or src/
    Purpose Request interception Request interception + full Node.js APIs
    Capabilities Limited Edge APIs Full 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):

    Profile stale revalidate expire Use Case
    default 5 min 15 min 1 year Standard content
    seconds 30 sec 1 sec 1 min Real-time data (aggressive!)
    minutes 5 min 1 min 1 hour Frequently updated
    hours 5 min 1 hour 1 day Multiple daily updates
    days 5 min 1 day 1 week Daily updates
    weeks 5 min 1 week 30 days Weekly updates
    max 5 min 30 days 1 year Rarely 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:

    • cacheTag()
    • updateTag() - Server Actions only
    • revalidateTag() - Server Actions + Route Handlers

    Two Invalidation Strategies:

    Function Where Behavior Use Case
    updateTag(tag) Server Actions only Immediate - next request waits for fresh data Read-your-own-writes
    revalidateTag(tag, profile) Server Actions + Route Handlers Stale-while-revalidate - serves cached while fetching Background 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:

    • Cache Components / Partial Prerendering
    • use cache directive
    • use cache: private directive
    • cacheLife() function
    • cacheTag() function
    • revalidateTag() function
    • updateTag() function
    • cookies() function
    • headers() function
    • connection() function

    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).
    
    Recommended Servers
    Vercel Grep
    Vercel Grep
    InstantDB
    InstantDB
    AurelianFlo
    AurelianFlo
    Repository
    jaredpalmer/claude-plugins
    Files