Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
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.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>
);
}mkdir -p .claude/skills/vercel-react-best-practices
# Copy the Recipe section above into:
# .claude/skills/vercel-react-best-practices/SKILL.mdThis skill works well combined with:
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.
| 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 |
// 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),
]);Promise.all() for independent async operationsimport { Button } from "@/components" pulls in the entire barrelimport { Button } from "@/components/ui/button" only imports what is needed// 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>
);await auth() at the start of every Server Action// Risky: renders "0" when count is 0
{count && <Badge count={count} />}
// Safe: explicit ternary
{count > 0 ? <Badge count={count} /> : null}&& with a number can render 0 or false as visible text// Bad: runs expensive computation on every render
const [data, setData] = useState(expensiveComputation());
// Good: runs only on mount
const [data, setData] = useState(() => expensiveComputation());useState so the expensive initial value is computed only onceuseMemo, useCallback, and React.memo rules become less relevant// Bad: passes entire user object to client
<ClientProfile user={user} />
// Good: passes only needed fields
<ClientProfile name={user.name} avatar={user.avatar} />await into the branch where the value is actually used// 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);Set for O(1) membership checks instead of Array.includes()Map for repeated lookups instead of Array.find() in loopsReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥