| 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: 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: 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
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 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 userevalidateTag(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:
-
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 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
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).