- 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](https://nextjs.org/docs/app/getting-started/partial-prerendering#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`)**
```typescript
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)**
```typescript
// 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)**
```typescript
// 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)**
```typescript
// 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**
```typescript
// 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
```typescript
// ❌ 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](https://nextjs.org/docs/app/getting-started/partial-prerendering)
**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):**
```typescript
// 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:**
```typescript
// ❌ 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](https://nextjs.org/docs/app/api-reference/directives/use-cache-private)
**When to Use**: For **user-specific** data where each user needs their own cache entry (dashboards, feeds, personalized recommendations).
**✅ CORRECT Pattern:**
```typescript
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):**
```typescript
// 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) |
Auf GitHub ansehen