| name | nextjs-reviewer |
| description | Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration. Ensure adherence to Next.js 16+ Cache Components best practices. |
Next.js PPR & Caching Code Review Skill
Purpose
Review Next.js App Router code for optimal Partial Prerendering (PPR), caching strategy, Suspense boundaries, and React Query integration. Ensure adherence to Next.js 16+ Cache Components best practices.
Documentation Version: Based on Next.js 16.0.4 official documentation
Last Updated: 2025-11-25
Source: https://nextjs.org/docs/app/getting-started/partial-prerendering
When to Use
- Before creating pull requests with Next.js components
- When implementing new data-fetching features
- During performance optimization reviews
- When adding or modifying Suspense boundaries
- After implementing caching strategies
Prerequisites
- Next.js 16+ with
cacheComponents: true in next.config
- App Router (not Pages Router)
- Understanding of Server Components vs Client Components
Understanding the Two Cache Systems
📖 Reference: Cache Components - With runtime data
Before reviewing code, understand these two completely different caching mechanisms:
| 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';
async function getSessionFromCookie() {
const cookieStore = await cookies();
const session = cookieStore.get('github_session')?.value;
return session ? decrypt(session) : null;
}
export const getCurrentUser = cache(async () => {
const session = await getSessionFromCookie();
if (!session) return null;
return {
accessToken: session.githubToken,
userId: session.githubId,
userName: session.userName,
};
});
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
Step 2: Layout (STATIC SHELL - no runtime data)
export default function RootLayout({ children }: { children: React.ReactNode }) {
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)
import { Suspense } from 'react';
export default function PullsPage() {
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)
import { requireAuth } from '@/auth/server';
export async function AuthenticatedPullsList() {
const user = await requireAuth();
return <PullsListCached accessToken={user.accessToken} />;
}
Step 5: Cached Data Component
import { cacheLife, cacheTag } from 'next/cache';
async function PullsListCached({ accessToken }: { accessToken: string }) {
'use cache';
cacheLife('minutes');
cacheTag('user-pulls');
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
export default async function Layout({ children }) {
const user = await getCurrentUser();
return <div>{children}</div>;
}
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):
export async function DataSection() {
const user = await getCurrentUser();
if (!user?.accessToken) redirect('/login');
return <DataSectionCached accessToken={user.accessToken} />;
}
async function DataSectionCached({ accessToken }: { accessToken: string }) {
'use cache';
cacheLife('minutes');
const client = getCachedAuthenticatedClient(accessToken);
const data = await fetchData(client);
return <UI data={data} />;
}
export default function Page() {
return (
<Suspense fallback={<DataSkeleton />}>
<DataSection />
</Suspense>
);
}
❌ INCORRECT Pattern:
export async function DataSection() {
const user = await getCurrentUser();
const client = getCachedAuthenticatedClient(user.accessToken);
const data = await fetchData(client);
return <UI data={data} />;
}
Check for:
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';
export default function Page() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserDashboard />
</Suspense>
);
}
async function UserDashboard() {
'use cache: private';
cacheLife({ stale: 60 });
const session = await cookies();
const userId = session.get('userId')?.value;
const data = await fetchUserSpecificData(userId);
return <Dashboard data={data} />;
}
Real-World Example (GitHub-style):
async function MyPullsPage() {
'use cache: private';
cacheLife('minutes');
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:
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):
export default function Page({ params }: { params: { slug: string } }) {
const slug = params.slug;
return <h1>{slug}</h1>;
}
✅ CORRECT (Next.js 15+):
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>;
}
'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+):
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:
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:
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const response = NextResponse.next();
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:
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:
'use cache';
cacheLife('minutes');
'use cache';
cacheLife('hours');
'use cache';
cacheLife('days');
'use cache';
cacheLife({
stale: 3600,
revalidate: 900,
expire: 86400,
});
Check for:
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:
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)
async function ServerData() {
'use cache';
cacheLife('hours');
const data = await fetch('/api/data');
return <UI data={data} />;
}
Client Components with SSR: Prefetch + Hydration Pattern
import { getQueryClient } from '@/app/get-query-client';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
async function DataWrapper({ userId }: { userId: string }) {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['data', userId],
queryFn: () => fetchData(userId),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DataClient userId={userId} />
</HydrationBoundary>
);
}
'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:
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
shouldRedactErrors: () => false,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}
Check for:
5. Request Deduplication
📖 Reference: React cache() for request memoization
React's cache() wrapper:
import { cache } from 'react';
const getUserUncached = async (client: Client) => {
const { data } = await usersGetAuthenticated({ client });
return data;
};
export const getUser = cache(getUserUncached);
Check for:
6. Common Anti-Patterns
📖 Reference: use cache - Constraints
❌ Avoid these patterns:
async function BadCached() {
'use cache';
const cookieStore = await cookies();
return <div />;
}
async function GoodCachedPrivate() {
'use cache: private';
cacheLife({ stale: 60 });
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
return <div>{userId}</div>;
}
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>;
}
async function BadConnection() {
'use cache: private';
await connection();
return <div />;
}
<Suspense fallback={<LoadingPage />}>
<Header /> {}
<DynamicContent />
</Suspense>
<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:
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:
8. Next.js MCP Runtime Validation
Use Next.js MCP tools to check for runtime issues:
mcp__next-devtools__nextjs_index()
mcp__next-devtools__nextjs_call({
port: "3000",
toolName: "get_errors"
})
mcp__next-devtools__nextjs_call({
port: "3000",
toolName: "get_routes"
})
Check for:
Advanced Patterns
9. Cache Invalidation
📖 References:
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';
async function Posts() {
'use cache';
cacheTag('posts');
const posts = await fetchPosts();
return <PostList posts={posts} />;
}
async function createPost(data: FormData) {
'use server';
await db.posts.create(data);
updateTag('posts');
}
revalidateTag - Stale-while-revalidate:
import { revalidateTag } from 'next/cache';
async function refreshPosts() {
'use server';
await db.posts.create(data);
revalidateTag('posts', 'max');
}
⚠️ 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:
Common Fixes
Fix 1: Split Auth from Data Fetching
Before:
export async function Component() {
const user = await getCurrentUser();
const data = await fetchData(user.accessToken);
return <UI data={data} />;
}
After:
export async function Component() {
const user = await getCurrentUser();
return <ComponentCached accessToken={user.accessToken} />;
}
async function ComponentCached({ accessToken }: { accessToken: string }) {
'use cache';
cacheLife('minutes');
const data = await fetchData(accessToken);
return <UI data={data} />;
}
Fix 2: Deep Suspense Boundaries
Before:
<Suspense fallback={<FullPageLoader />}>
<Header />
<Content />
<Footer />
</Suspense>
After:
<Header />
<Suspense fallback={<ContentLoader />}>
<Content />
</Suspense>
<Footer />
Fix 3: Add Cache to Data Fetching
Before:
async function Component() {
const data = await fetch('/api/data');
return <UI data={data} />;
}
After:
async function Component({ userId }: { userId: string }) {
'use cache';
cacheLife('hours');
cacheTag(`user-${userId}-data`);
const data = await fetch(`/api/data?user=${userId}`);
return <UI data={data} />;
}
Performance Metrics
After implementing PPR with proper caching:
Expected improvements:
- ✅ Time to First Byte (TTFB): < 200ms (static shell)
- ✅ First Contentful Paint (FCP): < 1s (static shell visible)
- ✅ Largest Contentful Paint (LCP): < 2.5s (with streaming)
- ✅ Reduced API calls: 50-90% reduction via caching
- ✅ Lower server load: Cached responses served without DB/API hits
Monitor:
- Build output for route types (◐ vs ○ vs ƒ)
- Runtime errors via Next.js MCP
- Cache hit rates in production
- API rate limit usage (should decrease)
Documentation References
📚 CRITICAL: All patterns in this skill are based on official Next.js 16.0.4+ documentation.
Core Documentation:
Query via MCP:
mcp__next-devtools__nextjs_docs({
action: 'get',
path: '/docs/app/getting-started/partial-prerendering',
})
Summary Checklist
For every PR with data-fetching components:
Core PPR Patterns:
Breaking Changes (Next.js 15+/16):
React Query Integration:
Cache Invalidation:
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).