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.
/plugin marketplace add jaredpalmer/claude-plugins/plugin install jp@jpThis skill inherits all available tools. When active, it can use any tool Claude has access to.
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 componentsuseSuspenseQuery used (not useQuery)staleTime: 60000 configured to prevent refetchshouldDehydrateQuery includes pending queriesš 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:
cache()'use cache' (different purposes)getCurrentUser() wrapped with cache()š 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:
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:
cookies()/headers() inside 'use cache' (use 'use cache: private' instead)connection() in ANY cache directiveAfter 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 PPRCheck for:
ā symbolĘ (fully dynamic) routesĘ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:
š 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.
š 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:
useOptimistic used for pending stateuseTransition wraps async mutationBefore:
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} />;
}
Before:
<Suspense fallback={<FullPageLoader />}>
<Header />
<Content />
<Footer />
</Suspense>
After:
<Header />
<Suspense fallback={<ContentLoader />}>
<Content />
</Suspense>
<Footer />
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} />;
}
After implementing PPR with proper caching:
Expected improvements:
Monitor:
š CRITICAL: All patterns in this skill are based on official Next.js 16.0.4+ documentation.
Core Documentation:
use cache directiveuse cache: private directivecacheLife() functioncacheTag() functionrevalidateTag() functionupdateTag() functioncookies() functionheaders() functionconnection() functionQuery via MCP:
mcp__next-devtools__nextjs_docs({
action: 'get',
path: '/docs/app/getting-started/partial-prerendering',
})
For every PR with data-fetching components:
Core PPR Patterns:
'use cache: private''use cache' + cacheLife()'use cache: private' + cacheLife() (min 30s stale)connection() NOT used inside any cache directive<Suspense> at usagecacheLife profiles for data freshnesscache() used for request deduplicationā for dynamic pagesBreaking Changes (Next.js 15+/16):
params and searchParams use await (Server) or use() (Client)Promise<...> for params/searchParamsproxy.ts instead of deprecated middleware.tsReact Query Integration:
'use cache' (NOT React Query)prefetchQuery called WITHOUT awaituseSuspenseQuery used instead of useQuerystaleTime: 60000Cache Invalidation:
updateTag() for immediate invalidation (Server Actions only)revalidateTag(tag, profile) for stale-while-revalidate (always pass profile!)cacheTag()When reviewing code, provide:
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).