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.
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
cacheComponents: true in next.config📖 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') |
┌─────────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────────┘
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>
);
}
| 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 |
// ❌ 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>
);
}
| 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 |
📖 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:
'use cache' directive at top of cached function/componentcacheLife() called with appropriate duration<Suspense> at usage siteuse 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:
'use cache: private'cacheLife with stale >= 30 seconds'use cache'<Suspense> at usage siteconnection() NOT used inside any cache directive📖 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:
params accesses use await (Server Components) or use() (Client Components)searchParams accesses use await or use()Promise<...> not plain objectssearchParams are wrapped in <Suspense>PageProps<'/route/[param]'> helper for type safety📖 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:
proxy.ts instead of deprecated middleware.tsmatcher config excludes metadata files if neededproxy.ts)📖 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'seconds' profile is very aggressive (1s revalidate)'minutes' (1 min revalidate)'hours'/'days'cacheTag() for on-demand revalidation📖 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:
key prop used when data depends on params: key={query || 'default'}📖 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:
'use cache' (NOT React Query)prefetchQuery called WITHOUT awaitHydrationBoundary wraps client componentsSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer