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: truein 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: privatedirective
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
cacheLifewithstale>= 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
searchParamstriggers dynamic rendering. Always wrap components that accesssearchParamsin<Suspense>boundaries to maximize the static shell.
Check for:
- [ ] All
paramsaccesses useawait(Server Components) oruse()(Client Components) - [ ] All
searchParamsaccesses useawaitoruse() - [ ] TypeScript types show
Promise<...>not plain objects - [ ] Components accessing
searchParamsare 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.tsinstead of deprecatedmiddleware.ts - [ ]
matcherconfig 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
staletime (exceptsecondsat 30s). Therevalidatetime 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
keyprop 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) - [ ]
prefetchQuerycalled WITHOUTawait - [ ]
HydrationBoundarywraps client components - [ ]
useSuspenseQueryused (notuseQuery) - [ ]
staleTime: 60000configured to prevent refetch - [ ]
shouldDehydrateQueryincludes 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 withcache()
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:
- Build ID - Unique per build
- Function ID - Secure hash of function location and signature
- Serializable arguments - Props (for components) or function arguments
- 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 onlyrevalidateTag()- 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 userevalidateTag(tag, 'max')or specify a cache profile.
10. Optimistic Updates with useOptimistic
π Reference: React
useOptimistichook
'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:
- [ ]
useOptimisticused for pending state - [ ]
useTransitionwraps 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 cachedirectiveuse cache: privatedirectivecacheLife()functioncacheTag()functionrevalidateTag()functionupdateTag()functioncookies()functionheaders()functionconnection()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
cacheLifeprofiles 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):
- [ ]
paramsandsearchParamsuseawait(Server) oruse()(Client) - [ ] TypeScript types show
Promise<...>for params/searchParams - [ ] Project uses
proxy.tsinstead of deprecatedmiddleware.ts
React Query Integration:
- [ ] Server Components use
'use cache'(NOT React Query) - [ ] Client SSR uses prefetchQuery + HydrationBoundary
- [ ]
prefetchQuerycalled WITHOUT await - [ ]
useSuspenseQueryused instead ofuseQuery - [ ] 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:
- Summary: Overall PPR readiness (Ready / Needs Work)
- Issues Found: List specific anti-patterns with file:line
- Recommendations: Concrete fixes with code examples
- Build Verification: Check build output for route types
- 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).