πŸ“‹

Best Practices

Battle-tested patterns and guidelines for building production-grade applications with React Server Components and the Next.js App Router

Great RSC applications aren't just about knowing what Server and Client Components are β€” they're about cultivating a server-first instinct, making deliberate decisions about boundaries, fetching data as close to where it's used as possible, and protecting sensitive logic behind the serialization boundary. This guide distills the most important principles across every layer of the stack into actionable, copy-paste-ready patterns.

Component BoundariesData FetchingCaching StrategyStreaming & SuspenseServer ActionsSecurityPerformanceTypeScript PatternsError HandlingBundle OptimizationColocated QueriesParallel Fetchingserver-onlyRevalidation

Component Boundary Decisions πŸ—ΊοΈ

The most impactful skill in RSC development β€” knowing exactly where to draw the "use client" line and how to keep it as leaf-like as possible.

The Golden Rule: Default to Server. Opt-in to Client.

Every component in the App Router is a Server Component by default. Add "use client" only when you genuinely need browser interactivity, local state, or browser APIs. The boundary should be as close to the leaves of your component tree as possible β€” wrapping only the interactive fragment, not the entire section.

Quick Decision Guide

πŸ”΅

Does it need useState or useReducer?

β†’ Client Component
πŸ”΅

Does it use useEffect or lifecycle hooks?

β†’ Client Component
πŸ”΅

Does it handle onClick, onChange, or form events?

β†’ Client Component
πŸ”΅

Does it use browser APIs (window, navigator, IntersectionObserver)?

β†’ Client Component
🟒

Does it fetch data from a database or external API?

β†’ Server Component
🟒

Does it read environment secrets or server-only config?

β†’ Server Component
🟒

Is it pure markup with no interactivity?

β†’ Server Component
βœ— Wide Client Boundary (costly)
Entire section becomes client JS β€” even static parts
// βœ— Entire ProductPage is client-side
"use client";
async function ProductPage({ id }) {
// Can't be async β€” we're in a Client Component
// Can't do direct DB access either
const [product, setProduct] = useState(null);
const [qty, setQty] = useState(1);
// Large component shipped to every user
return (
<div>
<ProductHeader product={product} />
<ProductDescription product={product} />
<ProductReviews productId={id} />
<QuantitySelector qty={qty}
onChange={setQty} />
<AddToCartButton />
</div>
);
}
βœ“ Narrow Client Boundary (optimal)
Only the interactive fragment ships as client JS
// βœ“ Only interactive leaf is a Client Component
// Server Component β€” fetches data, zero bundle cost
async function ProductPage({ id }) {
const product = await db.product.find(id);
return (
<div>
<ProductHeader product={product} /> {/* SC */}
<ProductDescription product={product} />{/* SC */}
<ProductReviews productId={id} /> {/* SC */}
<AddToCart product={product} /> {/* CC ← only this */}
</div>
);
}
// βœ“ Tiny Client Component β€” just the button
"use client";
function AddToCart({ product }) {
const [qty, setQty] = useState(1);
return (
<div>
<QuantitySelector qty={qty} onChange={setQty} />
<button onClick={() => addToCart(product.id, qty)}>
Add to Cart
</button>
</div>
);
}
Context Providers β€” Thin Client Wrapper Pattern
Providers must be Client Components. Keep them thin so Server Component children pass through.
// providers/ThemeProvider.tsx
"use client";
import { ThemeContext } from "./context";
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ↑ CC β€” necessary because useState is used
// app/layout.tsx (Server Component)
import { ThemeProvider } from "@/providers/ThemeProvider";
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
{children} {/* ← SC children pass through */}
</ThemeProvider>
</body>
</html>
);
}
// ↑ children are SC β€” still rendered server-side
// ThemeProvider just wraps without touching them
Passing Server Components as Props
Server Components can live inside Client Component subtrees via children or any slot prop

A Client Component cannot import a Server Component (it would be treated as a Client Component at the boundary). But a Server Component can pass a Server Component as the children prop or any other prop to a Client Component β€” and it stays server-rendered.

// βœ“ Server Component passes SC children into a CC wrapper
// app/dashboard/page.tsx β€” Server Component
import { AnimatedLayout } from "@/components/AnimatedLayout"; // CC
import { DataTable } from "@/components/DataTable"; // SC
import { MetricsPanel } from "@/components/MetricsPanel"; // SC
async function DashboardPage() {
const [metrics, tableData] = await Promise.all([
fetchMetrics(),
fetchTableData(),
]);
return (
<AnimatedLayout
sidebar={<MetricsPanel data={metrics} />} {/* SC as prop */}
>
<DataTable data={tableData} /> {/* SC as children */}
</AnimatedLayout>
);
}
// AnimatedLayout.tsx β€” Client Component
"use client";
function AnimatedLayout({ children, sidebar }) {
const [open, setOpen] = useState(true);
return (
<div className={open ? "expanded" : "collapsed"}>
<aside>{sidebar}</aside> {/* server-rendered MetricsPanel */}
<main>{children}</main> {/* server-rendered DataTable */}
</div>
);
}

Data Fetching Guidelines πŸ“‘

RSC makes data fetching composable, colocated, and deduplication-safe. These patterns maximize performance while keeping your code readable.

Principle: Fetch data as close to where it's used as possible.

In RSC, you don't need to "lift state up" to a parent just so it can hand data down via props. Each Server Component can fetch its own data directly. Wrap shared fetches in React.cache() to deduplicate identical calls within a single render tree β€” no network request fires twice.

βœ“ Parallel Fetching (always prefer)
Fire all independent requests simultaneously with Promise.all
// βœ“ Both fetches run concurrently
async function ProfilePage({ userId }: { userId: string }) {
const [user, posts, followers] = await Promise.all([
getUser(userId),
getUserPosts(userId),
getFollowers(userId),
]);
// Time = max(user, posts, followers) not sum
// If each takes 100ms: total = 100ms not 300ms
return (
<>
<ProfileHeader user={user} />
<FollowerCount count={followers.length} />
<PostFeed posts={posts} />
</>
);
}
βœ— Sequential Waterfall (avoid unless dependent)
Each await blocks the next β€” turns into a slow waterfall
// βœ— Waterfall β€” posts waits for user to finish!
async function ProfilePage({ userId }: { userId: string }) {
const user = await getUser(userId); // 100ms
const posts = await getUserPosts(userId); // 100ms (waits!)
const followers = await getFollowers(userId); // 100ms (waits!)
// Total = 300ms instead of 100ms
return (
<>
<ProfileHeader user={user} />
<FollowerCount count={followers.length} />
<PostFeed posts={posts} />
</>
);
}
// βœ“ Sequential is only justified when the second
// request depends on the first's return value:
async function Page({ userId }) {
const user = await getUser(userId);
// Recommendations need user.preferences β€” can't parallelize
const recs = await getRecommendations(user.preferences);
return <Feed user={user} items={recs} />;
}
Colocated Queries with React.cache()
Multiple components can call the same query β€” React deduplicates automatically within the render tree

lib/queries.ts β€” wrap with cache()

import { cache } from "react";
import { db } from "@/lib/db";
// Deduplicated per React render tree
export const getUser = cache(async (id: string) => {
console.log("DB hit for user", id); // fires ONCE
return db.user.findUnique({ where: { id } });
});
export const getProduct = cache(
async (id: string) => db.product.findUnique({ where: { id } })
);

Multiple components calling the same query

// Header.tsx β€” Server Component
import { getUser } from "@/lib/queries";
async function Header() {
const user = await getUser("u_1"); // ← DB hit #1
return <Avatar src={user.avatar} />;
}
// Sidebar.tsx β€” Server Component
import { getUser } from "@/lib/queries";
async function Sidebar() {
const user = await getUser("u_1"); // ← DEDUPLICATED βœ“
return <p>Welcome, {user.name}</p>;
}
// Both called in the same render tree.
// React.cache() ensures only ONE DB query fires.
βœ— Fetching in Client Component with useEffect
Adds client bundle weight, causes loading flicker, no caching
"use client";
// βœ— Avoid unless you genuinely can't use a SC
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/products")
.then(r => r.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <Spinner />;
// Problem: visible loading flash on every visit
// Problem: fetch happens AFTER hydration
// Problem: no server caching
return products.map(p => <ProductCard key={p.id} product={p} />);
}
βœ“ Fetch in Server Component + stream with Suspense
No client JS, instant streamed HTML, server-cached
import { Suspense } from "react";
import { ProductCardSkeleton } from "./skeletons";
// βœ“ Server Component β€” no "use client" needed
async function ProductList() {
const products = await getProducts(); // cached + deduped
return products.map(p => (
<ProductCard key={p.id} product={p} />
));
}
// Parent wraps with Suspense for streaming
function ProductPage() {
return (
<Suspense
fallback={
<div className="grid gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
}
>
<ProductList />
</Suspense>
);
}
// Result: HTML streams in, no useEffect, no flicker
Avoid Prop Drilling β€” Each Component Owns Its Data
RSC eliminates the need to thread data through multiple component layers
βœ— Before RSC β€” prop drilling
// Page fetches everything and drills it down
async function Page({ userId }) {
const user = await getUser(userId);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <Avatar user={user} />;
}
// user passed through Layout and Sidebar
// just to reach Avatar β€” fragile & verbose
βœ“ With RSC β€” colocated, no prop drilling
// Each component fetches exactly what it needs
async function Page({ userId }) {
return <Layout userId={userId} />;
}
// Layout doesn't care about user data
function Layout({ userId }) {
return <Sidebar userId={userId} />;
}
// Avatar fetches its own data β€” React.cache dedupes
async function Avatar({ userId }) {
const user = await getUser(userId); // cache hit if called before
return <img src={user.avatarUrl} />;
}
// No prop drilling. user is fetched once thanks to cache()
⚠ Watch for N+1 Query Problems
Colocated queries are powerful but can still cause N+1 if used naively in loops
// βœ— N+1 β€” one DB query per post
async function PostFeed({ authorId }) {
const posts = await getPosts(authorId); // 1 query
return posts.map(post => (
<PostCard key={post.id} post={post} />
));
}
async function PostCard({ post }) {
// βœ— Fires once for EVERY post!
const author = await getUser(post.authorId);
return <div>{author.name}: {post.title}</div>;
}
// 1 + N queries total β€” very expensive at scale
// βœ“ Batch fetch β€” one query for all authors
async function PostFeed({ authorId }) {
const posts = await getPosts(authorId); // 1 query
// Fetch all authors in one round-trip
const authorIds = [...new Set(posts.map(p => p.authorId))];
const authors = await getUsersByIds(authorIds); // 1 query
const authorMap = new Map(authors.map(a => [a.id, a]));
return posts.map(post => (
<PostCard
key={post.id}
post={post}
author={authorMap.get(post.authorId)}
/>
));
}
// Total: 2 queries regardless of post count βœ“

Caching Strategy πŸ—„οΈ

Next.js has four distinct caching layers. Understanding which layer applies β€” and how to intentionally control each β€” is the difference between a fast app and an unpredictably stale one.

πŸ”
Request Memoization
Single render tree

Use React.cache() to wrap any function that fetches data. Two Server Components calling the same wrapped function with the same argument share one result β€” no extra DB round-trips.

πŸ’Ύ
Data Cache (fetch)
Across requests & deploys

Next.js extends fetch() with { next: { revalidate } }. Tag responses with next.tags to enable on-demand purging via revalidateTag().

πŸ—‚οΈ
Full Route Cache
Build time β†’ invalidated on redeploy or revalidation

Applies to statically rendered routes. Any dynamic function (cookies, headers, searchParams) opts the route out automatically.

⚑
Router Cache
Browser session (30s dynamic / 5min static)

RSC payloads are cached in the browser on <Link> prefetch. Call router.refresh() to bust stale data after a mutation.

fetch() Cache Options β€” Choose Per Call
Match the cache directive to the data's freshness requirements
// Static data β€” cache indefinitely (default)
const config = await fetch("/api/config");
// Time-based revalidation β€” refresh every hour
const prices = await fetch("/api/prices", {
next: { revalidate: 3600 },
});
// Never cache β€” always fresh (dynamic data)
const cart = await fetch("/api/cart", {
cache: "no-store",
});
// Tag for on-demand invalidation
const products = await fetch("/api/products", {
next: {
tags: ["products"],
revalidate: 300, // also revalidate every 5 min
},
});
// Invalidation from a Server Action
"use server";
import {
revalidateTag,
revalidatePath,
} from "next/cache";
export async function updateProduct(id: string, data) {
await db.product.update({ where: { id }, data });
// Purge all responses tagged "products"
revalidateTag("products");
// Also purge the product detail page
revalidatePath(`/products/${id}`);
}
// On-demand purge via route handler
// app/api/revalidate/route.ts
export async function POST(req: Request) {
const { tag, secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET)
return Response.json({ error: "Unauthorized" }, { status: 401 });
revalidateTag(tag);
return Response.json({ revalidated: true });
}
Dynamic Signals β€” Opting Out of Full Route Cache
These calls make a route dynamic. Use them deliberately, not accidentally.
// Any of these opts the ENTIRE route out of
// Full Route Cache:
import { cookies, headers } from "next/headers";
import { unstable_noStore as noStore } from "next/cache";
// Reading cookies or headers
const token = (await cookies()).get("token");
// Reading searchParams (in Pages, not layouts)
// export default function Page({ searchParams }) {...}
// Calling noStore() explicitly
noStore(); // force-dynamic for this component
// fetch with cache: "no-store"
await fetch(url, { cache: "no-store" });
// ⚠ Only call these when you actually need
// per-request dynamic data. Static is always faster.
// βœ“ Route caching config via segment options
// app/dashboard/layout.tsx
// Force all routes in this segment to be dynamic
export const dynamic = "force-dynamic";
// Or set a default revalidation interval
export const revalidate = 60; // seconds
// Force static (will error if dynamic APIs used)
export const dynamic = "force-static";
// Per-page revalidation overrides layout config:
// app/dashboard/page.tsx
export const revalidate = 300; // 5 min for this page only
// βœ“ Best practice: be explicit. Don't let
// accidental cookies() calls make routes dynamic.
React.cache() β€” Best Practices
Deduplicate data access functions per render β€” not per deployment
// βœ“ Wrap at the data-access layer, not at
// the component level
// lib/queries/user.ts
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({
where: { id },
select: { id: true, name: true, avatar: true, role: true },
});
});
export const getUserWithPosts = cache(
async (id: string) => {
return db.user.findUnique({
where: { id },
include: { posts: { take: 10, orderBy: { createdAt: "desc" } } },
});
}
);
// Each unique (function + args) gets one cached result.
// βœ“ OK to call in multiple components
// React.cache is per-render, not global state
// Navbar.tsx
const user = await getUser(session.userId);
// returns cached result if called elsewhere in tree
// Breadcrumbs.tsx
const user = await getUser(session.userId); // ← cache HIT βœ“
// Profile.tsx
const user = await getUser(session.userId); // ← cache HIT βœ“
// ⚠ React.cache() resets on every new
// incoming Request β€” it is NOT shared
// across users or deployments.
// Use fetch() + Data Cache for cross-request caching.

Streaming & Suspense Patterns ⚑

Streaming transforms a slow all-or-nothing page load into a progressively revealed experience. Suspense is the mechanism β€” use it deliberately to unblock important content and keep users engaged.

Principle: Identify the Critical Path, Suspend Everything Else

Render your layout, navigation, and above-the-fold content immediately. Wrap slow data β€” recommendations, activity feeds, secondary panels β€” in <Suspense> so they stream in without blocking the critical path. Users see a useful page in milliseconds, not seconds.

loading.tsx β€” Route-level Streaming
Next.js automatically wraps your page in a Suspense boundary
// app/products/loading.tsx
// Automatically shown while page.tsx suspends
import { ProductGridSkeleton } from "@/components/skeletons";
import { hl } from "@/lib/Hl";
export default function Loading() {
return (
<div className="container">
<div className="mb-8">
{/* Skeleton for page header */}
<div className="h-10 w-64 rounded bg-muted animate-pulse" />
<div className="mt-2 h-5 w-96 rounded bg-muted animate-pulse" />
</div>
<ProductGridSkeleton count={12} />
</div>
);
}
// βœ“ Layout and navigation render immediately
// βœ“ Loading skeleton shown until page data resolves
// βœ“ Matches the shape of the real content
Inline Suspense β€” Granular Streaming
Wrap individual slow sections β€” unblock the rest of the page
import { Suspense } from "react";
// βœ“ Fast parts render immediately
// βœ“ Slow parts stream in without blocking others
async function ProductPage({ id }) {
const product = await getProduct(id); // fast ( <30ms cached)
return (
<div>
<ProductHeader product={product} /> {/* fast */}
<ProductDescription product={product} /> {/* fast */}
{/* Stream in slow sections independently */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} /> {/* slow β€” DB query */}
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} /> {/* slow β€” ML service */}
</Suspense>
</div>
);
}
Design Skeletons to Match the Real Content
Good skeletons reduce perceived loading time and prevent layout shift
// βœ— Generic spinner β€” no shape hint
function Fallback() {
return (
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
);
}
// Problems:
// - Doesn't hint at the shape of incoming content
// - Causes large layout shift when real content arrives
// - Feels slow even when data comes back quickly
// βœ“ Shape-matched skeleton β€” same layout as content
function ProductCardSkeleton() {
return (
<div className="rounded-lg border p-4 space-y-3">
{/* Matches image area */}
<div className="h-48 rounded-md bg-muted animate-pulse" />
{/* Matches title */}
<div className="h-5 w-3/4 rounded bg-muted animate-pulse" />
{/* Matches price */}
<div className="h-4 w-1/4 rounded bg-muted animate-pulse" />
{/* Matches button */}
<div className="h-9 w-full rounded bg-muted animate-pulse" />
</div>
);
}
// Same grid dimensions as the real card β€” zero layout shift
Nested Suspense β€” Prioritised Reveal Order
Inner boundaries resolve independently; the outermost fallback covers the whole subtree
// βœ“ Nested boundaries stream independently
async function DashboardPage() {
return (
<div className="grid grid-cols-3 gap-6">
{/* Critical: resolves first */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* DB query ~50ms */}
</Suspense>
{/* Secondary: can take longer */}
<div className="col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart /> {/* analytics query ~300ms */}
</Suspense>
{/* Nested: only shown after ActivityChart area is ready */}
<Suspense fallback={<FeedSkeleton />}>
<RecentActivity /> {/* feed query ~200ms */}
</Suspense>
</div>
</div>
);
}
// MetricsPanel streams in at ~50ms
// RecentActivity streams in at ~200ms
// ActivityChart streams in at ~300ms
// Page feels fast from the first 50ms βœ“
βœ— Suspense Too High Up
Wrapping the whole page defeats the purpose of streaming
// βœ— One Suspense for the entire page
// Fast sections also wait for the slowest fetch
function Dashboard() {
return (
<Suspense fallback={<FullPageLoader />}>
<MetricsPanel /> {/* 50ms */}
<RecentActivity /> {/* 200ms */}
<ActivityChart /> {/* 300ms β€” everything waits for this */}
</Suspense>
);
}
// User sees a spinner for 300ms, then everything at once
// Effectively same as not streaming at all
βœ“ Granular Suspense Boundaries
Each section reveals as soon as its own data is ready
// βœ“ Independent boundaries β€” content streams progressively
function Dashboard() {
return (
<>
{/* Shows at ~50ms */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
{/* Shows at ~200ms */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
{/* Shows at ~300ms */}
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart />
</Suspense>
</>
);
}
// Progressive reveal β€” perceived performance is far better βœ“
Streaming Data into Client Components with use()
Pass a Promise from a Server Component to a Client Component β€” the CC suspends until resolved
// Server Component β€” creates the Promise
async function Page({ userId }) {
// Don't await β€” pass the Promise directly
const userPromise = getUser(userId);
return (
<Suspense fallback={<ProfileSkeleton />}>
{/* Client Component receives the Promise */}
<ProfileCard userPromise={userPromise} />
</Suspense>
);
}
// Client Component β€” unwraps with use()
"use client";
import { use } from "react";
function ProfileCard({
userPromise,
}: {
userPromise: Promise<User>;
}) {
// use() suspends until the Promise resolves
// Suspense boundary above catches the suspension
const user = use(userPromise);
return (
<div>
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
</div>
);
}
// βœ“ Data starts fetching on the server
// βœ“ Client component suspends gracefully
// βœ“ No waterfall: fetch starts before hydration

Server Actions Best Practices βš™οΈ

Server Actions are powerful β€” but they're POST endpoints. Treat them with the same security discipline you'd apply to any API route.

πŸ”’Always Authenticate

Every action must verify the caller's session. Never trust that only authorised users will call an action.

βœ…Validate All Inputs

Use Zod or a similar schema library to validate FormData or object arguments before touching the database.

πŸ”„Revalidate After Mutations

Call revalidatePath() or revalidateTag() to bust stale caches so the UI reflects the new state.

🚨Return Typed Errors

Return structured error objects instead of throwing so useActionState() can display field-level feedback.

πŸ“Co-locate in actions/ Files

Put related actions in a dedicated 'use server' file β€” never inline 'use server' in a Client Component.

πŸ”‘Authorise Resources

After authenticating the user, verify they have permission to access the specific resource being mutated.

Production-Ready Server Action
Authentication + authorisation + validation + error handling + cache revalidation
// actions/post.ts
"use server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidatePath, revalidateTag } from "next/cache";
// Zod schema β€” validates inputs before touching DB
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
categoryId: z.string().uuid(),
});
type ActionState = {
errors?: { title?: string[]; content?: string[] };
message?: string;
success?: boolean;
};
export async function createPost(
_prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// 1. Authenticate
const session = await auth();
if (!session?.user?.id) {
return { message: "Unauthorised" };
}
// 2. Validate
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
categoryId: formData.get("categoryId"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
// 3. Authorise resource (verify category exists & is accessible)
const category = await db.category.findUnique({
where: { id: parsed.data.categoryId },
});
if (!category) return { message: "Category not found" };
// 4. Mutate
await db.post.create({
data: {
...parsed.data,
authorId: session.user.id,
},
});
// 5. Revalidate caches
revalidateTag("posts");
revalidatePath("/blog");
return { success: true, message: "Post created!" };
}
// components/CreatePostForm.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createPost } from "@/actions/post";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Post"}
</button>
);
}
export function CreatePostForm() {
const [state, action] = useActionState(
createPost,
{ errors: {} },
);
return (
<form action={action} className="space-y-4">
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" />
{state.errors?.title && (
<p className="text-sm text-red-500">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" />
{state.errors?.content && (
<p className="text-sm text-red-500">
{state.errors.content[0]}
</p>
)}
</div>
{state.message && !state.success && (
<p className="text-red-500">{state.message}</p>
)}
{state.success && (
<p className="text-green-600">{state.message}</p>
)}
<SubmitButton />
</form>
);
}
Optimistic Updates with useOptimistic()
Update the UI immediately β€” roll back automatically on failure
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/post";
function LikeButton({ postId, initialLikes, initialLiked }) {
const [isPending, startTransition] = useTransition();
const [optimisticState, updateOptimistic] = useOptimistic(
{ likes: initialLikes, liked: initialLiked },
(current, optimisticValue) => ({
...current,
...optimisticValue,
}),
);
function handleToggle() {
// Immediately update UI
updateOptimistic({
liked: !optimisticState.liked,
likes: optimisticState.likes + (optimisticState.liked ? -1 : 1),
});
startTransition(async () => {
// Server action runs in background
await toggleLike(postId);
// If action fails, React reverts optimistic state automatically
});
}
return (
<button onClick={handleToggle} disabled={isPending}>
{optimisticState.liked ? "❀️" : "🀍"} {optimisticState.likes}
</button>
);
}
Server Action Anti-patterns to Avoid
// βœ— No authentication β€” any user can call this
"use server";
export async function deletePost(postId: string) {
// No session check!
await db.post.delete({ where: { id: postId } });
revalidatePath("/blog");
}
// βœ— No authorisation β€” user A can delete user B's post
export async function deletePost(postId: string) {
const session = await auth(); // authenticated
if (!session) throw new Error("Unauthorised");
// But no check that session.user owns this post!
await db.post.delete({ where: { id: postId } });
}
// βœ“ Authentication AND authorisation
"use server";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorised");
// Verify ownership β€” only the author can delete
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
});
if (!post) throw new Error("Post not found");
if (post.authorId !== session.user.id)
throw new Error("Forbidden"); // can't delete others' posts
await db.post.delete({ where: { id: postId } });
revalidatePath("/blog");
}

Security Practices πŸ”

RSC gives you powerful server-side access β€” secrets, databases, file systems. With that power comes the responsibility to never let server-only logic slip across the boundary.

Use server-only to Guard Sensitive Modules
Guarantee a module never ends up in the client bundle β€” throws a build-time error if you accidentally import it from a Client Component
// lib/db.ts β€” database client with env secrets
import "server-only"; // ← add this import
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as typeof global & {
prisma?: PrismaClient;
};
export const db =
globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production")
globalForPrisma.prisma = db;
// If any Client Component imports this file, Next.js
// throws a build-time error:
// "This module cannot be imported from a Client Component"
// ↑ Protects DATABASE_URL and other secrets βœ“
// lib/auth.ts β€” session management
import "server-only";
import { getServerSession } from "next-auth";
import { authOptions } from "./authOptions";
export async function auth() {
return getServerSession(authOptions);
}
export async function requireAuth() {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthenticated");
}
return session;
}
// lib/analytics.ts β€” internal analytics with API key
import "server-only";
import { hl } from "@/lib/Hl";
export async function trackEvent(event: string) {
await fetch("https://analytics.internal/track", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ANALYTICS_KEY}`,
},
body: JSON.stringify({ event }),
});
}
Environment Variable Rules
Know exactly which env vars are exposed to the browser β€” and which aren't
Server-Only (never sent to browser)
# .env.local
# βœ“ No NEXT_PUBLIC_ prefix β†’ server only
DATABASE_URL="postgres://..."
STRIPE_SECRET_KEY="sk_live_..."
JWT_SECRET="super-secret-value"
SENDGRID_API_KEY="SG...."
OPENAI_API_KEY="sk-..."
ANALYTICS_KEY="internal-key"
# These are NEVER included in the client bundle.
# Safe to use in Server Components and Server Actions.
Public (sent to browser in bundle)
# .env.local
# ⚠ NEXT_PUBLIC_ prefix β†’ included in client bundle
# Visible to anyone who inspects the JS
NEXT_PUBLIC_APP_URL="https://myapp.com"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."
NEXT_PUBLIC_POSTHOG_KEY="phc_..."
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1..."
# Only put keys here that are DESIGNED to be public.
# Publishable/public-facing API keys only.
# NEVER put secrets with NEXT_PUBLIC_.
Never Pass Secrets as Props to Client Components
Props crossing the serialization boundary are embedded in the RSC payload β€” visible in the browser
// βœ— SECRET LEAKED β€” apiKey goes to the browser
async function Page() {
const apiKey = process.env.INTERNAL_API_KEY;
const user = await fetchUser(apiKey);
// user object β€” ok to pass
// apiKey β€” βœ— NEVER pass secrets as props!
return (
<UserCard
user={user}
apiKey={apiKey} {/* ← visible in RSC payload βœ— */}
/>
);
}
// Even if UserCard is a Server Component today,
// if it ever becomes a Client Component, the key
// becomes part of the browser-visible payload.
// βœ“ Use the secret server-side; pass only the result
async function Page() {
// Use secret on the server β€” never prop-drill it
const user = await fetchUser(
process.env.INTERNAL_API_KEY // used server-side βœ“
);
// Pass only the data the client component needs
return (
<UserCard
name={user.name}
avatar={user.avatar}
// apiKey omitted βœ“
/>
);
}
// Or for complex derived data:
async function Page() {
const enrichedUser = await getEnrichedUser(); // SC work
// Pass the enriched result β€” not the key used to get it
return <Profile user={enrichedUser} />;
}
Sanitise & Validate All External Input
Treat route params, searchParams, form data, and headers as untrusted β€” always validate with a schema
// βœ— Trusting params directly β€” SQL injection / chaos
async function ProductPage({
params,
}: {
params: { id: string };
}) {
// βœ— What if id = "1; DROP TABLE products;--"?
const product = await db.product.findUnique({
where: { id: params.id }, // UUID assumed, not enforced
});
return <ProductCard product={product} />;
}
// βœ— Trusting searchParams directly
async function SearchPage({ searchParams }) {
const q = searchParams.q; // could be anything
const results = await search(`%${q}%`); // SQL injection!
}
import { z } from "zod";
import { notFound } from "next/navigation";
// βœ“ Validate params at the top of every page/layout
const ParamsSchema = z.object({
id: z.string().uuid("Invalid product ID"),
});
async function ProductPage({ params }) {
const result = ParamsSchema.safeParse(params);
if (!result.success) notFound();
const product = await db.product.findUnique({
where: { id: result.data.id }, // validated UUID βœ“
});
if (!product) notFound();
return <ProductCard product={product} />;
}
// βœ“ Validate and sanitise searchParams too
const SearchSchema = z.object({
q: z.string().max(200).default(""),
page: z.coerce.number().int().positive().default(1),
});
async function SearchPage({ searchParams }) {
const { q, page } = SearchSchema.parse(searchParams);
// q is trimmed to 200 chars, page is a valid integer
const results = await search(q, page);
}
React Taint APIs β€” Prevent Accidental Secret Exposure
Experimental: mark values as tainted so React throws if they're passed to a Client Component
// Opt-in in next.config.ts
// experimental: { taint: true }
import {
experimental_taintObjectReference,
experimental_taintUniqueValue,
} from "react";
// lib/user.ts
export async function getUser(id: string) {
const user = await db.user.findUnique({ where: { id } });
// Taint the entire object β€” can't be passed to CC
experimental_taintObjectReference(
"User data must not be passed to client components",
user,
);
// Taint a specific sensitive value
experimental_taintUniqueValue(
"Password hash must never reach the client",
user,
user.passwordHash,
);
return user;
}
// Now if any Server Component tries to pass user or
// user.passwordHash as a prop to a Client Component,
// React throws a runtime error β€” even in development.

Performance Optimizations πŸš€

RSC dramatically reduces the default JavaScript footprint. These patterns ensure you get the maximum benefit β€” smaller bundles, faster interactions, and optimal Core Web Vitals.

Principle: Zero Cost by Default β€” Don't Give It Back

Server Components contribute zero bytes to the client bundle. Every time you reach for "use client" unnecessarily β€” or import a large library inside a Client Component β€” you're giving back the performance advantage RSC gives you. Heavy processing, large libraries, and markdown/syntax-highlighting should stay on the server.

βœ— Heavy Library in Client Component
Ships the full library to every user's browser
// βœ— Sends ~2MB of syntax-highlighting library to browser
"use client";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { hl } from "@/lib/Hl";
function CodeBlock({ code, language }) {
return (
<SyntaxHighlighter language={language} style={vscDarkPlus}>
{code}
</SyntaxHighlighter>
);
}
// react-syntax-highlighter: ~2.5 MB minified!
// Every user downloads this on first load.
βœ“ Heavy Library Stays on Server β€” Zero Bundle Cost
HTML with syntax colours ships β€” not the library
// βœ“ Runs on server β€” library doesn't ship to browser
import { codeToHtml } from "shiki";
// Server Component β€” no "use client"
async function CodeBlock({
code,
language,
}: {
code: string;
language: string;
}) {
// shiki runs on server, produces static HTML
const html = await codeToHtml(code, {
lang: language,
theme: "github-dark",
});
return (
<div
className="rounded-md overflow-auto"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// Client bundle impact: 0 bytes β€” only HTML/CSS sent βœ“
Dynamic Imports for Client-Side-Only Libraries
Use next/dynamic with ssr: false for libraries that require the browser
// βœ“ Lazy-load heavy Client Components
import dynamic from "next/dynamic";
// Only loads when this component renders
const RichTextEditor = dynamic(
() => import("@/components/RichTextEditor"),
{
loading: () => <EditorSkeleton />,
ssr: false, // ← for browser-only libs (e.g. CodeMirror)
},
);
// βœ“ Code-split the chart library
const Chart = dynamic(
() => import("@/components/Chart"),
{
loading: () => <ChartSkeleton />,
// ssr: true (default) β€” pre-renders static on server
},
);
function DashboardPage() {
return (
<>
<Chart data={data} /> {/* pre-rendered + lazy hydrated */}
<RichTextEditor /> {/* browser-only, loads on demand */}
</>
);
}
// βœ“ Dynamic import with named export
const HeavyModal = dynamic(
() => import("@/components/modals").then((m) => m.HeavyModal),
{ loading: () => <ModalSkeleton /> },
);
// βœ“ Conditionally load β€” only when actually needed
function Page() {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>
Open Editor
</button>
{/* Editor only loads after button click */}
{showEditor && <RichTextEditor />}
</div>
);
}
// Without dynamic import, RichTextEditor would be
// downloaded on page load even if never opened.
// With dynamic + condition: downloaded only when needed.
Image Best Practices with next/image
Use the Image component β€” never a raw img tag
// βœ— Raw img tag β€” no optimization
<img
src="/hero.jpg"
alt="Hero"
className="w-full"
/>
// Problems:
// - Full resolution served to all devices
// - No WebP/AVIF conversion
// - No lazy loading by default
// - No CLS prevention (no size reservation)
// - No blur placeholder
// Hero image: potentially 2-8 MB to mobile users!
import Image from "next/image";
// βœ“ next/image β€” automatic optimization
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // ← eager load for above-the-fold
placeholder="blur" // ← low-res blur while loading
className="w-full rounded-lg object-cover"
sizes="(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw"
/>
// For below the fold β€” omit priority (lazy by default)
<Image
src={product.imageUrl}
alt={product.name}
width={400} height={400}
// lazy loaded automatically βœ“
/>
// Result: WebP/AVIF, right size per device, lazy, CLS-free
Navigation Performance β€” Link Prefetching
Control prefetch behaviour to balance instant navigations against bandwidth usage
import Link from "next/link";
// βœ“ Default <Link> β€” prefetches on hover/viewport entry
// Ideal for main navigation
<Link href="/products">Browse Products</Link>
// βœ“ Disable for heavy/noisy routes
// Avoid prefetching heavy dashboards automatically
<Link href="/reports/full" prefetch={false}>
Full Analytics Report
</Link>
// βœ“ Programmatic navigation
import { useRouter } from "next/navigation";
const router = useRouter();
// Navigate + bust Router Cache (re-fetches current route)
router.push("/cart");
router.refresh();
// Replace current history entry (no back button entry)
router.replace("/login");

Prefetch Behaviour Summary

Static segment + <Link>

β†’ Full payload prefetched

Dynamic segment + <Link>

β†’ Prefetches up to nearest loading.tsx

<Link prefetch={false}>

β†’ No prefetch (on demand only)

router.push()

β†’ No prefetch β€” navigates immediately

Font Optimization with next/font
Zero layout shift, self-hosted automatically, no third-party DNS lookups
// app/layout.tsx
// βœ“ next/font β€” downloaded at build time, self-hosted
import { Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans", // CSS variable for Tailwind
display: "swap", // show fallback while loading
preload: true,
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500", "700"],
});
// Or a local font
const customFont = localFont({
src: "./fonts/CustomFont.woff2",
variable: "--font-custom",
});
export default function RootLayout({ children }) {
return (
<html
lang="en"
className={`${inter.variable} ${mono.variable}`)}
>
<body className="font-sans">{children}</body>
</html>
);
}
// βœ“ No external network request for fonts at runtime
// βœ“ Font files served from your own domain
// βœ“ Automatic size-adjust prevents CLS

TypeScript Patterns πŸ”·

Type-safe RSC apps catch boundary violations, parameter mismatches, and data contract breaks at compile time β€” not at 2 AM in production.

Typing Page and Layout Props
Next.js provides PageProps and LayoutProps generics
// app/products/[id]/page.tsx
// βœ“ Explicit params + searchParams typing
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<{
sort?: "price" | "rating" | "newest";
page?: string;
}>;
};
export default async function ProductPage({
params,
searchParams,
}: Props) {
const { id } = await params;
const { sort = "newest", page = "1" } = await searchParams;
const product = await getProduct(id);
if (!product) notFound();
return <ProductView product={product} sort={sort} />;
}
// βœ“ Generate static params with correct return type
export async function generateStaticParams(): Promise<
Array<{ id: string }>
> {
const products = await getTopProducts();
return products.map((p) => ({ id: p.id }));
}
Typing Server Actions
Strongly-typed action state for useActionState() integration
// types/actions.ts β€” shared action state type
export type ActionResult<T = void> =
| { success: true; data: T; message?: string }
| { success: false; errors?: Partial<Record<string, string[]>>;
message: string };
// actions/post.ts
"use server";
export async function createPost(
_prev: ActionResult,
formData: FormData,
): Promise<ActionResult<{ postId: string }>> {
const parsed = CreatePostSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return {
success: false,
errors: parsed.error.flatten().fieldErrors,
message: "Validation failed",
};
}
const post = await db.post.create({ data: parsed.data });
return { success: true, data: { postId: post.id } };
}
// In Client Component β€” TypeScript knows the shape:
const [state] = useActionState(createPost, {
success: false,
message: "",
});
// state.errors is typed, state.data is typed βœ“
Typed Data Access Layer
Infer DB types from Prisma/Drizzle and derive component prop types from them β€” single source of truth
// lib/types.ts β€” derive types from Prisma
import { Prisma } from "@prisma/client";
// Full model type
export type User = Prisma.UserGetPayload<{}>;
// Subset type β€” only what the card needs
export type UserCardData = Prisma.UserGetPayload<{
select: {
id: true;
name: true;
avatar: true;
role: true;
};
}>;
// With relations
export type PostWithAuthor = Prisma.PostGetPayload<{
include: { author: { select: { name: true; avatar: true } } };
}>;
// Array variant
export type PostFeed = PostWithAuthor[];
// βœ“ If Prisma schema changes, types update automatically.
// No manual type maintenance.
// lib/queries.ts β€” typed return values
import { cache } from "react";
import type { UserCardData, PostWithAuthor } from "./types";
export const getUserCard = cache(
async (id: string): Promise<UserCardData | null> =>
db.user.findUnique({
where: { id },
select: { id: true, name: true, avatar: true, role: true },
})
);
export const getPost = cache(
async (id: string): Promise<PostWithAuthor | null> =>
db.post.findUnique({
where: { id },
include: {
author: { select: { name: true, avatar: true } },
},
})
);
// Component using the typed query:
async function UserCard({ userId }: { userId: string }) {
const user = await getUserCard(userId);
if (!user) notFound();
// user is UserCardData β€” TypeScript knows every field βœ“
return <div>{user.name}</div>;
}
Always Narrow Nullable Query Results
Use notFound() for missing resources β€” don't render null-unsafe pages
// βœ— Not narrowing β€” TypeScript can't help you
async function Page({ params }) {
const product = await getProduct(params.id);
// βœ— product could be null!
return (
<div>
<h1>{product.name}</h1> {/* TypeError at runtime βœ— */}
<p>{product.description}</p>
</div>
);
}
import { notFound } from "next/navigation";
// βœ“ Narrow immediately β€” then TypeScript knows it's non-null
async function Page({ params }) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound(); // renders app/not-found.tsx
// After notFound(), TypeScript narrows product to non-null βœ“
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
// For arrays β€” safe to skip null check (empty array is fine)
async function ProductList() {
const products = await getProducts(); // Product[] (never null)
if (products.length === 0) return <EmptyState />;
return products.map(p => <ProductCard key={p.id} product={p} />);
}
Use satisfies for Config Objects
Get type checking without widening the inferred type β€” keeps literal types
// βœ“ satisfies checks the type but preserves literal inference
type NavItem = {
label: string;
href: string;
icon: string;
};
// satisfies NavItem[] β†’ TypeScript checks shape
// but NAV_ITEMS retains full literal types
const NAV_ITEMS = [
{ label: "Home", href: "/", icon: "🏠" },
{ label: "Products", href: "/products",icon: "πŸ›οΈ" },
{ label: "Blog", href: "/blog", icon: "πŸ“" },
] satisfies NavItem[];
// NAV_ITEMS[0].href is "/", not string β€” autocomplete works βœ“
// vs. type annotation which widens:
const NAV: NavItem[] = [...]; // href is "string" β€” less precise
// Useful for metadata, route config, theme objects, etc.
export const metadata = {
title: "My App",
description: "...",
} satisfies Metadata;

Error Handling Patterns 🚨

Errors are inevitable β€” network timeouts, missing records, unauthorised access. The App Router provides a layered file-based system for handling them gracefully at the right granularity.

error.tsxClient Component

Catches runtime errors in the route segment. Receives error and reset(). Must be a Client Component ("use client").

not-found.tsxServer Component

Renders when notFound() is called from within the segment. Use for missing resources β€” 404 semantics.

global-error.tsxClient Component

Catches errors in the root layout. Rare edge case. Must render its own <html>/<body> tags since layout breaks.

loading.tsxServer Component

Not an error handler but part of the resilience story β€” shows while the route suspends.

error.tsx β€” Route Segment Error Boundary
Catch and recover from unexpected runtime errors within a segment
// app/products/error.tsx
"use client"; // ← Required β€” must be a Client Component
import { useEffect } from "react";
type Props = {
error: Error & { digest?: string };
reset: () => void; // retry β€” re-render the segment
};
export default function Error({ error, reset }: Props) {
useEffect(() => {
// Log to your error tracking service
console.error(error);
// reportError(error); // Sentry, Datadog, etc.
}, [error]);
return (
<div className="flex flex-col items-center gap-4 py-16">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground text-sm max-w-md text-center">
{error.message ?? "An unexpected error occurred."}
</p>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
>
Try again
</button>
</div>
);
}
// βœ“ Granular error boundaries β€” scope errors to segments
// app/
// layout.tsx (error boundary here = root errors)
// products/
// layout.tsx
// error.tsx ← catches errors in /products/**
// page.tsx
// [id]/
// error.tsx ← catches errors in /products/[id] only
// page.tsx
// If /products/[id]/page.tsx throws:
// β†’ /products/[id]/error.tsx handles it
// β†’ /products layout is still rendered βœ“
// β†’ Navigation is still visible βœ“
// β†’ Only the product detail section shows the error UI
// If you only have /products/error.tsx:
// β†’ It handles both /products and /products/[id] errors
// β†’ Useful for simple apps; too coarse for large UIs
not-found.tsx β€” Missing Resource UI
Call notFound() anywhere in a Server Component to render the nearest not-found.tsx
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
async function ProductPage({ params }) {
const { id } = await params;
// Validate first (see TypeScript section)
if (!z.string().uuid().safeParse(id).success) notFound();
const product = await getProduct(id);
// Resource doesn't exist β†’ 404
if (!product) notFound();
// Access control β†’ redirect to login, not 404
const session = await auth();
if (!session) redirect("/login");
// User doesn't have permission β†’ 403 (or 404 to avoid recon)
if (product.ownerId !== session.user.id) notFound();
return <ProductView product={product} />;
}
// app/products/[id]/not-found.tsx
// Context-aware 404 β€” stays inside the products layout
export default function ProductNotFound() {
return (
<div className="flex flex-col items-center gap-6 py-16">
<span className="text-6xl">οΏ½οΏ½</span>
<h1 className="text-2xl font-bold">Product not found</h1>
<p className="text-muted-foreground">
This product may have been removed or the link is broken.
</p>
<div className="flex gap-3">
<Link
href="/products"
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
>
Browse all products
</Link>
<Link
href="/"
className="rounded-md border px-4 py-2 text-sm"
>
Go home
</Link>
</div>
</div>
);
}
// βœ“ Navigation + layout still visible
// βœ“ Context-aware message
// βœ“ Actionable links to recover
Handle Errors in Server Actions β€” Never Just Throw
Return structured errors so Client Components can display field-level feedback
// βœ— Throwing generic errors β€” bad UX
"use server";
export async function updateProfile(formData: FormData) {
const name = formData.get("name") as string;
if (!name) throw new Error("Name required");
// This throws, but useActionState doesn't catch it cleanly.
// The error boundary above the form catches it instead β€”
// replacing the entire form with the error UI.
await db.user.update({ where: { id: ... }, data: { name } });
}
// βœ“ Return structured errors β€” display inline
"use server";
export async function updateProfile(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const result = ProfileSchema.safeParse({
name: formData.get("name"),
bio: formData.get("bio"),
});
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
try {
await db.user.update({ data: result.data, where: { ... } });
return { success: true };
} catch (err) {
// Unknown DB/network error β€” don't leak internals
console.error(err);
return {
success: false,
message: "Failed to update profile. Please try again.",
};
}
}
Manual Error Boundaries for Client Component Subtrees
Use react-error-boundary for fine-grained client-side error isolation
// βœ“ Wrap risky interactive widgets with an error boundary
"use client";
import { ErrorBoundary } from "react-error-boundary";
function WidgetError({ error, resetErrorBoundary }) {
return (
<div className="rounded-md border border-red-500/20 p-4 text-sm">
<p className="text-red-600">Widget failed to load</p>
<button
onClick={resetErrorBoundary}
className="mt-2 text-xs text-primary underline"
>
Retry
</button>
</div>
);
}
function Dashboard() {
return (
<div className="grid gap-6">
{/* Each widget is independently isolated */}
<ErrorBoundary FallbackComponent={WidgetError}>
<StockChart symbol="AAPL" />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={WidgetError}>
<NewsWidget feed="finance" />
</ErrorBoundary>
</div>
);
}
// If StockChart throws, only that widget shows an error.
// NewsWidget continues to function normally. βœ“

The Definitive Checklist βœ…

Before shipping any feature, run through these categories. A green light across all nine means your RSC code is production-ready.

Six Golden Rules

01
Server First

Every component starts as a Server Component. Add 'use client' only when you need real interactivity.

02
Leaf Boundaries

Push client boundaries as far toward the leaves as possible. Only the interactive fragment needs to be a CC.

03
Fetch Close

Fetch data in the component that uses it. Use React.cache() to deduplicate. Avoid prop drilling via colocated queries.

04
Stream Slow

Any component that might be slow gets its own Suspense boundary. Design skeletons before you design the real UI.

05
Secure Always

Every Server Action authenticates and authorises. Every module with secrets imports server-only. Props never carry secrets.

06
Cache Intentionally

Know which cache layer applies to each piece of data. Tag everything. Revalidate after every mutation.

Full Category Checklist

πŸ—ΊοΈ
Component Design
7 items
  • β˜‘Default to Server Components β€” add 'use client' only when needed
  • β˜‘Push 'use client' as far toward the leaves as possible
  • β˜‘Extract only the interactive fragment into a Client Component
  • β˜‘Never import a Server Component inside a Client Component
  • β˜‘Wrap Context providers in thin Client Component files
  • β˜‘Pass Server Components as children/props to Client Components
  • β˜‘Apply server-only to all modules with secrets or DB access
πŸ“‘
Data Fetching
7 items
  • β˜‘Fetch data in Server Components β€” avoid useEffect fetching
  • β˜‘Use Promise.all() for independent parallel fetches
  • β˜‘Wrap all data-access functions with React.cache()
  • β˜‘Keep queries colocated with the component that uses them
  • β˜‘Avoid N+1: batch queries when iterating over lists
  • β˜‘Sequential awaits only when one result depends on another
  • β˜‘Pass minimal data as props β€” never leak secrets across the boundary
πŸ—„οΈ
Caching
7 items
  • β˜‘Match fetch() cache option to data freshness requirements
  • β˜‘Tag all mutable data fetches with next: { tags: [...] }
  • β˜‘Call revalidateTag() / revalidatePath() after every mutation
  • β˜‘Use export const revalidate in route segments for time-based ISR
  • β˜‘Use dynamic APIs (cookies, headers) deliberately β€” they bypass Full Route Cache
  • β˜‘Prefer revalidateTag over revalidatePath for precise invalidation
  • β˜‘React.cache() deduplicates within a render tree β€” not across requests
⚑
Streaming & Suspense
7 items
  • β˜‘Wrap slow data in <Suspense> to unblock the critical path
  • β˜‘Use loading.tsx for segment-level streaming fallbacks
  • β˜‘Create shape-matched skeleton components β€” prevent layout shift
  • β˜‘Use granular Suspense boundaries β€” not one for the whole page
  • β˜‘Identify and prioritise the critical rendering path
  • β˜‘Use use() + Promise props to stream data into Client Components
  • β˜‘Place Suspense boundaries at the same level as the slow component
βš™οΈ
Server Actions
7 items
  • β˜‘Authenticate the caller at the start of every action
  • β˜‘Authorise access to the specific resource being mutated
  • β˜‘Validate all inputs with Zod before touching the database
  • β˜‘Return structured error objects β€” don't throw for user-facing errors
  • β˜‘Revalidate relevant caches after every successful mutation
  • β˜‘Store actions in dedicated 'use server' files β€” not inline
  • β˜‘Use useOptimistic() for instant UI feedback on toggles/likes
πŸ”
Security
7 items
  • β˜‘Import server-only in every sensitive module (db, auth, etc.)
  • β˜‘Never pass secrets or API keys as props to Client Components
  • β˜‘Use NEXT_PUBLIC_ only for keys designed to be public
  • β˜‘Validate and sanitise all route params and searchParams with Zod
  • β˜‘Consider experimental_taintObjectReference for sensitive objects
  • β˜‘Authenticate in both Server Components and Server Actions
  • β˜‘Return 404 (notFound) not 403 to avoid resource existence leakage
πŸš€
Performance
7 items
  • β˜‘Keep heavy libraries (shiki, unified, etc.) in Server Components
  • β˜‘Use next/dynamic with ssr: false for browser-only libraries
  • β˜‘Use next/image for all images β€” never raw <img> tags
  • β˜‘Use next/font β€” self-hosted, zero CLS, no external requests
  • β˜‘Set priority on above-the-fold images
  • β˜‘Disable prefetch={false} on heavy/rarely-visited routes
  • β˜‘Call router.refresh() after mutations to bust Router Cache
πŸ”·
TypeScript
7 items
  • β˜‘Type all page and layout props explicitly (params, searchParams)
  • β˜‘Derive model types from Prisma/Drizzle rather than hand-writing
  • β˜‘Type Server Action state with a shared ActionResult<T> type
  • β˜‘Narrow nullable query results with notFound() immediately
  • β˜‘Use satisfies for config objects to keep literal types
  • β˜‘Type-safe route params with Zod before using them
  • β˜‘Avoid as casts β€” use safeParse and proper narrowing
🚨
Error Handling
7 items
  • β˜‘Add error.tsx to every route segment with risky data fetching
  • β˜‘Add not-found.tsx at the appropriate segment level
  • β˜‘Call notFound() for missing resources β€” use redirect() for auth
  • β˜‘Report errors to a monitoring service in error.tsx useEffect
  • β˜‘Provide a reset() button so users can retry without full reload
  • β˜‘Use react-error-boundary for granular Client Component isolation
  • β˜‘Return field-level errors from Server Actions β€” don't throw
🎯

The RSC Mindset in One Sentence

Start on the server, stream what's slow, push interactivity to the leaves, colocate data with its component, protect secrets with boundaries, validate everything that comes in, and let the cache layers work in your favour β€” never against you.

Server FirstBundle MinimalStream EarlyCache IntentionallyValidate AlwaysType EverythingError Gracefully