Vercel React Best Practices Skill - An agent skill recipe for React and Next.js performance optimization
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
Recipe
The complete SKILL.md content you can copy into .claude/skills/vercel-react-best-practices/SKILL.md:
---
name: vercel-react-best-practices
description: "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, based on Vercel Engineering guidelines. Contains 69 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Eliminating Waterfalls | CRITICAL | async- |
| 2 | Bundle Size Optimization | CRITICAL | bundle- |
| 3 | Server-Side Performance | HIGH | server- |
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | client- |
| 5 | Re-render Optimization | MEDIUM | rerender- |
| 6 | Rendering Performance | MEDIUM | rendering- |
| 7 | JavaScript Performance | LOW-MEDIUM | js- |
| 8 | Advanced Patterns | LOW | advanced- |
---
## Category 1: Eliminating Waterfalls (CRITICAL)
### async-cheap-condition-before-await
Check cheap sync conditions before awaiting flags or remote values. If a condition can be checked synchronously, do it before the async call.
```tsx
// Bad: always awaits even when not needed
async function getContent(slug: string) \{
const flags = await getFeatureFlags();
if (!flags.newContent) return legacyContent(slug);
return newContent(slug);
\}
// Good: check cheap condition first
async function getContent(slug: string) \{
if (isLegacySlug(slug)) return legacyContent(slug); // sync check first
const flags = await getFeatureFlags();
if (!flags.newContent) return legacyContent(slug);
return newContent(slug);
\}
```
### async-defer-await
Move `await` into branches where the value is actually used. Do not await at the top when only some branches need the result.
```tsx
// Bad: always awaits
async function handler(req: Request) \{
const user = await getUser(req);
if (req.method === "OPTIONS") return new Response(null, \{ status: 204 \});
return new Response(JSON.stringify(user));
\}
// Good: defer await to where needed
async function handler(req: Request) \{
if (req.method === "OPTIONS") return new Response(null, \{ status: 204 \});
const user = await getUser(req);
return new Response(JSON.stringify(user));
\}
```
### async-parallel
Use `Promise.all()` for independent operations. Never sequentially await independent promises.
```tsx
// Bad: sequential (waterfall)
const user = await getUser(id);
const posts = await getPosts(id);
const stats = await getStats(id);
// Good: parallel
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id),
]);
```
### async-suspense-boundaries
Use Suspense boundaries to stream content. Each independent data-fetching section should have its own Suspense boundary.
```tsx
// Bad: one boundary, everything waits
<Suspense fallback=\{<Loading />\}>
<SlowSection />
<FastSection />
</Suspense>
// Good: independent streaming
<Suspense fallback=\{<Skeleton />\}>
<FastSection />
</Suspense>
<Suspense fallback=\{<Skeleton />\}>
<SlowSection />
</Suspense>
```
---
## Category 2: Bundle Size Optimization (CRITICAL)
### bundle-barrel-imports
Import directly from the module, not through barrel (index) files. Barrel files prevent tree-shaking.
```tsx
// Bad: pulls in entire barrel
import \{ Button \} from "@/components";
// Good: direct import
import \{ Button \} from "@/components/ui/button";
```
### bundle-dynamic-imports
Use `next/dynamic` for heavy components that are not needed on initial render.
```tsx
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./chart"), \{
loading: () => <Skeleton className="h-64" />,
\});
```
### bundle-defer-third-party
Load analytics, logging, and non-critical scripts after hydration.
```tsx
useEffect(() => \{
import("./analytics").then((mod) => mod.init());
\}, []);
```
### bundle-conditional
Load modules only when a feature is activated.
```tsx
async function handleExport() \{
const \{ exportToPDF \} = await import("./pdf-export");
await exportToPDF(data);
\}
```
### bundle-preload
Preload on hover/focus for perceived speed.
```tsx
<Link
href="/dashboard"
onMouseEnter=\{() => router.prefetch("/dashboard")\}
>
Dashboard
</Link>
```
---
## Category 3: Server-Side Performance (HIGH)
### server-auth-actions
Authenticate server actions like API routes. Every Server Action is a public endpoint.
```tsx
"use server";
export async function deletePost(id: string) \{
const session = await auth();
if (!session) throw new Error("Unauthorized");
await db.post.delete(\{ where: \{ id \} \});
\}
```
### server-cache-react
Use `React.cache()` for per-request deduplication of expensive computations.
```tsx
import \{ cache \} from "react";
export const getUser = cache(async (id: string) => \{
return db.user.findUnique(\{ where: \{ id \} \});
\});
```
### server-dedup-props
Avoid duplicate serialization in RSC props. Fetch data in the component that needs it rather than passing large objects through multiple layers.
### server-serialization
Minimize data passed to client components. Only pass the fields the client component actually needs.
```tsx
// Bad: passes entire user object
<ClientProfile user=\{user\} />
// Good: passes only needed fields
<ClientProfile name=\{user.name\} avatar=\{user.avatar\} />
```
### server-parallel-fetching
Restructure components to parallelize fetches. Each Server Component fetches its own data independently.
### server-after-nonblocking
Use `after()` for non-blocking operations like logging and analytics.
```tsx
import \{ after \} from "next/server";
export async function POST(request: Request) \{
const data = await processRequest(request);
after(() => \{
logAnalytics(\{ event: "processed", data: data.id \});
\});
return Response.json(data);
\}
```
---
## Category 4: Client-Side Data Fetching (MEDIUM-HIGH)
### client-swr-dedup
Use SWR for automatic request deduplication. Multiple components using the same key share one request.
### client-passive-event-listeners
Use passive listeners for scroll and touch events to avoid blocking the main thread.
```tsx
element.addEventListener("scroll", handler, \{ passive: true \});
```
### client-localstorage-schema
Version and minimize localStorage data. Parse with validation on read.
---
## Category 5: Re-render Optimization (MEDIUM)
### rerender-defer-reads
Do not subscribe to state that is only used inside event handlers.
```tsx
// Bad: re-renders on every count change
function Component() \{
const count = useStore((s) => s.count);
return <button onClick=\{() => console.log(count)\}>Log</button>;
\}
// Good: read in the handler
function Component() \{
return (
<button onClick=\{() => console.log(useStore.getState().count)\}>
Log
</button>
);
\}
```
### rerender-memo
Extract expensive child trees into memoized components.
### rerender-derived-state-no-effect
Derive state during render, not in effects.
```tsx
// Bad: useState + useEffect
const [filtered, setFiltered] = useState(items);
useEffect(() => \{
setFiltered(items.filter(predicate));
\}, [items, predicate]);
// Good: derive during render
const filtered = useMemo(() => items.filter(predicate), [items, predicate]);
```
### rerender-functional-setstate
Use functional setState for stable callbacks that do not need the current value in scope.
```tsx
// Bad: depends on count, unstable
const increment = () => setCount(count + 1);
// Good: stable, no dependency on count
const increment = () => setCount((prev) => prev + 1);
```
### rerender-lazy-state-init
Pass a function to useState for expensive initial values.
```tsx
// Bad: runs on every render
const [data, setData] = useState(expensiveComputation());
// Good: runs only on mount
const [data, setData] = useState(() => expensiveComputation());
```
### rerender-no-inline-components
Never define components inside other components.
```tsx
// Bad: InnerList is recreated every render, loses state
function Parent() \{
const InnerList = () => <ul>\{items.map(...)\}</ul>;
return <InnerList />;
\}
// Good: defined outside
function InnerList(\{ items \}) \{
return <ul>\{items.map(...)\}</ul>;
\}
function Parent() \{
return <InnerList items=\{items\} />;
\}
```
### rerender-transitions
Use `startTransition` for non-urgent updates to keep the UI responsive.
### rerender-use-deferred-value
Defer expensive renders to keep input responsive.
---
## Category 6: Rendering Performance (MEDIUM)
### rendering-content-visibility
Use `content-visibility: auto` for long lists and off-screen content.
```css
.card \{
content-visibility: auto;
contain-intrinsic-size: 0 200px;
\}
```
### rendering-hoist-jsx
Extract static JSX outside components to avoid recreating on every render.
### rendering-conditional-render
Use ternary, not `&&` for conditional rendering to avoid rendering `0` or `false`.
```tsx
// Risky: renders "0" when count is 0
\{count && <Badge count=\{count\} />\}
// Safe: explicit ternary
\{count > 0 ? <Badge count=\{count\} /> : null\}
```
### rendering-resource-hints
Use React DOM resource hints for preloading critical resources.
```tsx
import \{ preload, preconnect \} from "react-dom";
preload("/fonts/inter.woff2", \{ as: "font", type: "font/woff2" \});
preconnect("https://api.example.com");
```
---
## Category 7: JavaScript Performance (LOW-MEDIUM)
### js-batch-dom-css
Group CSS changes via classes or cssText instead of individual style property changes.
### js-index-maps
Build a Map for repeated lookups instead of using array.find() in a loop.
### js-set-map-lookups
Use Set for O(1) membership checks instead of array.includes().
```tsx
// Bad: O(n) per check
const isAdmin = adminIds.includes(userId);
// Good: O(1) per check
const adminSet = new Set(adminIds);
const isAdmin = adminSet.has(userId);
```
### js-combine-iterations
Combine multiple filter/map into one loop or use flatMap.
### js-early-exit
Return early from functions to avoid unnecessary computation.
---
## Category 8: Advanced Patterns (LOW)
### advanced-event-handler-refs
Store event handlers in refs to avoid re-subscribing effects.
```tsx
const handlerRef = useRef(onMessage);
handlerRef.current = onMessage;
useEffect(() => \{
const handler = (e: MessageEvent) => handlerRef.current(e);
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
\}, []); // never re-runs
```
### advanced-init-once
Initialize app-level setup exactly once per application load.
### advanced-use-latest
useLatest pattern for stable callback refs that always point to the latest closure.
---
## How to Apply Rules
When reviewing or writing code:
1. Start with CRITICAL rules (Categories 1-2) as they have the highest impact
2. Apply HIGH rules (Category 3) for server-side code
3. Check MEDIUM rules (Categories 4-6) for client-side components
4. Consider LOW rules (Categories 7-8) for hot paths and performance-critical code
When generating new code, always follow rules from Categories 1-3 by default. Only apply lower-priority rules when specifically optimizing.Working Example
Example prompt: "Review this component for performance issues"
// User's component
"use client";
import { useState, useEffect } from "react";
import { icons } from "lucide-react"; // barrel import
export default function Dashboard() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch("/api/user").then(r => r.json()).then(setUser);
}, []);
useEffect(() => {
if (user) fetch("/api/posts").then(r => r.json()).then(setPosts);
}, [user]);
const filtered = posts.filter(p => p.published);
return (
<div>
{user && <h1>{user.name}</h1>}
{filtered.map((post, i) => (
<div key={i} style={{ padding: "10px" }}>
<p onClick={() => console.log(post.id)}>{post.title}</p>
</div>
))}
</div>
);
}Example skill output:
The skill would identify these violations:
| Rule | Severity | Issue |
|---|---|---|
| server- (Category 3) | CRITICAL | Entire page is "use client" with useEffect fetching. Should be a Server Component |
| async-parallel | CRITICAL | Sequential waterfall: user fetch then posts fetch |
| bundle-barrel-imports | CRITICAL | Importing from barrel lucide-react pulls entire icon library |
| rerender-no-inline (Category 5) | MEDIUM | Inline style objects recreated each render |
| rerender- (Category 5) | MEDIUM | Array index as key on dynamic list |
And provide the refactored version:
// app/dashboard/page.tsx (Server Component - no "use client")
import { getUser, getPosts } from "@/lib/data";
export default async function Dashboard() {
const [user, posts] = await Promise.all([getUser(), getPosts()]);
const filtered = posts.filter(p => p.published);
return (
<div>
<h1>{user.name}</h1>
{filtered.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Deep Dive
How It Works
- The skill provides 69 rules organized by impact priority (CRITICAL to LOW)
- Each rule has a prefix that categorizes it (async-, bundle-, server-, etc.)
- When reviewing code, the skill checks against rules starting from the highest priority
- When generating code, the skill follows Categories 1-3 by default
- The rule-based approach ensures consistent, measurable performance improvements
Installation
mkdir -p .claude/skills/vercel-react-best-practices
# Copy the Recipe section above into:
# .claude/skills/vercel-react-best-practices/SKILL.mdCustomization
- Add project-specific rules to the bottom of the skill
- Remove categories that do not apply (e.g., Category 8 for simple projects)
- Adjust severity levels based on your performance targets
- Add your bundle size budgets and Core Web Vitals thresholds
Pairing with Other Skills
This skill works well combined with:
- react-19-mastery for React 19-specific patterns
- nextjs-data-fetching for data layer decisions
- typescript-react-patterns for type-safe implementations
Gotchas
-
Do not apply all 69 rules at once. Start with CRITICAL (Categories 1-2) for the highest ROI. Lower priority rules matter less until the critical ones are addressed.
-
React Compiler changes memoization rules. If using React Compiler, Category 5 (rerender-) rules around manual useMemo/useCallback become less relevant. The compiler handles most of these automatically.
-
Some rules conflict with readability. Rules like js-combine-iterations (combining filter+map into one loop) can hurt readability for minimal performance gain. Apply only on hot paths.
-
Server Component rules only apply to Next.js App Router. If using Pages Router or plain React, Category 3 (server-) rules do not apply.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| ESLint react-hooks/exhaustive-deps | Automated lint-time checks for hook deps | Need broader performance review |
| @next/bundle-analyzer | Analyzing actual bundle output | Need code-level rule checking |
| React DevTools Profiler | Runtime profiling of real renders | Want pre-commit static analysis |
| Lighthouse CI | Automated CWV checks in CI | Need code-level recommendations |
FAQs
What are the two CRITICAL priority rule categories and why do they matter most?
- Category 1: Eliminating Waterfalls -- sequential async operations are the biggest performance killer
- Category 2: Bundle Size Optimization -- large bundles slow initial load and hurt Core Web Vitals
- These two categories should be addressed before any other optimizations
How does the async-parallel rule prevent waterfalls?
// Bad: sequential (waterfall)
const user = await getUser(id);
const posts = await getPosts(id);
// Good: parallel
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);- Use
Promise.all()for independent async operations - Never sequentially await promises that do not depend on each other
Why should you import directly from modules instead of barrel files?
- Barrel (index) files prevent tree-shaking
import { Button } from "@/components"pulls in the entire barrelimport { Button } from "@/components/ui/button"only imports what is needed
What is the rerender-defer-reads rule and when does it apply?
// Bad: re-renders on every count change
const count = useStore((s) => s.count);
return <button onClick={() => console.log(count)}>Log</button>;
// Good: read state only inside the handler
return (
<button onClick={() => console.log(useStore.getState().count)}>
Log
</button>
);- Do not subscribe to state that is only used inside event handlers
How should you use Suspense boundaries for streaming?
- Each independent data-fetching section should have its own Suspense boundary
- Avoid wrapping a slow and fast section in the same boundary (everything waits for the slowest)
- Separate boundaries allow fast content to stream immediately
Gotcha: Why should every Server Action be authenticated?
- Every Server Action is a public HTTP endpoint
- An unauthenticated action allows anyone to call it directly
- Always check
await auth()at the start of every Server Action
What is the rendering-conditional-render rule about?
// Risky: renders "0" when count is 0
{count && <Badge count={count} />}
// Safe: explicit ternary
{count > 0 ? <Badge count={count} /> : null}- Using
&&with a number can render0orfalseas visible text - Always use a ternary or explicit boolean check
How does the rerender-lazy-state-init rule improve performance?
// Bad: runs expensive computation on every render
const [data, setData] = useState(expensiveComputation());
// Good: runs only on mount
const [data, setData] = useState(() => expensiveComputation());- Pass a function to
useStateso the expensive initial value is computed only once
Gotcha: Do Category 5 rerender rules still apply when using React Compiler?
- The React Compiler handles most memoization automatically
- Manual
useMemo,useCallback, andReact.memorules become less relevant - However, structural rules (no inline components, lazy state init) still apply
What is the server-serialization rule and how do you follow it?
// Bad: passes entire user object to client
<ClientProfile user={user} />
// Good: passes only needed fields
<ClientProfile name={user.name} avatar={user.avatar} />- Minimize data passed from Server Components to Client Components
- Only pass the specific fields the client component actually needs
How does the async-defer-await rule work?
- Move
awaitinto the branch where the value is actually used - If some code paths do not need the async result, skip the await for those paths
- This avoids unnecessary latency on branches that return early
What TypeScript pattern does the js-set-map-lookups rule recommend?
// Bad: O(n) per check
const isAdmin = adminIds.includes(userId);
// Good: O(1) per check
const adminSet = new Set(adminIds);
const isAdmin = adminSet.has(userId);- Use
Setfor O(1) membership checks instead ofArray.includes() - Use
Mapfor repeated lookups instead ofArray.find()in loops
Related
- Re-renders - preventing unnecessary re-renders
- Bundle Optimization - reducing bundle size
- Core Web Vitals - CWV optimization
- Performance Checklist - audit checklist
- Data Fetching Performance - eliminating waterfalls