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.
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.
Does it need useState or useReducer?
β Client ComponentDoes it use useEffect or lifecycle hooks?
β Client ComponentDoes it handle onClick, onChange, or form events?
β Client ComponentDoes it use browser APIs (window, navigator, IntersectionObserver)?
β Client ComponentDoes it fetch data from a database or external API?
β Server ComponentDoes it read environment secrets or server-only config?
β Server ComponentIs it pure markup with no interactivity?
β Server Component// β 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 eitherconst [product, setProduct] = useState(null);const [qty, setQty] = useState(1);// Large component shipped to every userreturn (<div><ProductHeader product={product} /><ProductDescription product={product} /><ProductReviews productId={id} /><QuantitySelector qty={qty}onChange={setQty} /><AddToCartButton /></div>);}
// β Only interactive leaf is a Client Component// Server Component β fetches data, zero bundle costasync 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>);}
// 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
children or any slot propA 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 Componentimport { AnimatedLayout } from "@/components/AnimatedLayout"; // CCimport { DataTable } from "@/components/DataTable"; // SCimport { MetricsPanel } from "@/components/MetricsPanel"; // SCasync function DashboardPage() {const [metrics, tableData] = await Promise.all([fetchMetrics(),fetchTableData(),]);return (<AnimatedLayoutsidebar={<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>);}
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.
// β Both fetches run concurrentlyasync 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 300msreturn (<><ProfileHeader user={user} /><FollowerCount count={followers.length} /><PostFeed posts={posts} /></>);}
// β Waterfall β posts waits for user to finish!async function ProfilePage({ userId }: { userId: string }) {const user = await getUser(userId); // 100msconst posts = await getUserPosts(userId); // 100ms (waits!)const followers = await getFollowers(userId); // 100ms (waits!)// Total = 300ms instead of 100msreturn (<><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 parallelizeconst recs = await getRecommendations(user.preferences);return <Feed user={user} items={recs} />;}
React.cache()lib/queries.ts β wrap with cache()
import { cache } from "react";import { db } from "@/lib/db";// Deduplicated per React render treeexport const getUser = cache(async (id: string) => {console.log("DB hit for user", id); // fires ONCEreturn 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 Componentimport { getUser } from "@/lib/queries";async function Header() {const user = await getUser("u_1"); // β DB hit #1return <Avatar src={user.avatar} />;}// Sidebar.tsx β Server Componentimport { 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.
"use client";// β Avoid unless you genuinely can't use a SCfunction 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 cachingreturn products.map(p => <ProductCard key={p.id} product={p} />);}
import { Suspense } from "react";import { ProductCardSkeleton } from "./skeletons";// β Server Component β no "use client" neededasync function ProductList() {const products = await getProducts(); // cached + dedupedreturn products.map(p => (<ProductCard key={p.id} product={p} />));}// Parent wraps with Suspense for streamingfunction ProductPage() {return (<Suspensefallback={<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
// Page fetches everything and drills it downasync 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
// Each component fetches exactly what it needsasync function Page({ userId }) {return <Layout userId={userId} />;}// Layout doesn't care about user datafunction Layout({ userId }) {return <Sidebar userId={userId} />;}// Avatar fetches its own data β React.cache dedupesasync function Avatar({ userId }) {const user = await getUser(userId); // cache hit if called beforereturn <img src={user.avatarUrl} />;}// No prop drilling. user is fetched once thanks to cache()
// β N+1 β one DB query per postasync function PostFeed({ authorId }) {const posts = await getPosts(authorId); // 1 queryreturn 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 authorsasync function PostFeed({ authorId }) {const posts = await getPosts(authorId); // 1 query// Fetch all authors in one round-tripconst authorIds = [...new Set(posts.map(p => p.authorId))];const authors = await getUsersByIds(authorIds); // 1 queryconst authorMap = new Map(authors.map(a => [a.id, a]));return posts.map(post => (<PostCardkey={post.id}post={post}author={authorMap.get(post.authorId)}/>));}// Total: 2 queries regardless of post count β
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.
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.
Next.js extends fetch() with { next: { revalidate } }. Tag responses with next.tags to enable on-demand purging via revalidateTag().
Applies to statically rendered routes. Any dynamic function (cookies, headers, searchParams) opts the route out automatically.
RSC payloads are cached in the browser on <Link> prefetch. Call router.refresh() to bust stale data after a mutation.
// Static data β cache indefinitely (default)const config = await fetch("/api/config");// Time-based revalidation β refresh every hourconst 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 invalidationconst 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 pagerevalidatePath(`/products/${id}`);}// On-demand purge via route handler// app/api/revalidate/route.tsexport 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 });}
// 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 headersconst token = (await cookies()).get("token");// Reading searchParams (in Pages, not layouts)// export default function Page({ searchParams }) {...}// Calling noStore() explicitlynoStore(); // 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 dynamicexport const dynamic = "force-dynamic";// Or set a default revalidation intervalexport 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.tsxexport const revalidate = 300; // 5 min for this page only// β Best practice: be explicit. Don't let// accidental cookies() calls make routes dynamic.
// β Wrap at the data-access layer, not at// the component level// lib/queries/user.tsimport { 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.tsxconst user = await getUser(session.userId);// returns cached result if called elsewhere in tree// Breadcrumbs.tsxconst user = await getUser(session.userId); // β cache HIT β// Profile.tsxconst 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 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.
// app/products/loading.tsx// Automatically shown while page.tsx suspendsimport { 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
import { Suspense } from "react";// β Fast parts render immediately// β Slow parts stream in without blocking othersasync 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>);}
// β Generic spinner β no shape hintfunction 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 contentfunction 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 boundaries stream independentlyasync 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 β
// β One Suspense for the entire page// Fast sections also wait for the slowest fetchfunction 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
// β Independent boundaries β content streams progressivelyfunction 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 β
use()// Server Component β creates the Promiseasync function Page({ userId }) {// Don't await β pass the Promise directlyconst 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 suspensionconst 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 are powerful β but they're POST endpoints. Treat them with the same security discipline you'd apply to any API route.
Every action must verify the caller's session. Never trust that only authorised users will call an action.
Use Zod or a similar schema library to validate FormData or object arguments before touching the database.
Call revalidatePath() or revalidateTag() to bust stale caches so the UI reflects the new state.
Return structured error objects instead of throwing so useActionState() can display field-level feedback.
Put related actions in a dedicated 'use server' file β never inline 'use server' in a Client Component.
After authenticating the user, verify they have permission to access the specific resource being mutated.
// 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 DBconst 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. Authenticateconst session = await auth();if (!session?.user?.id) {return { message: "Unauthorised" };}// 2. Validateconst 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. Mutateawait db.post.create({data: {...parsed.data,authorId: session.user.id,},});// 5. Revalidate cachesrevalidateTag("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>);}
"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 UIupdateOptimistic({liked: !optimisticState.liked,likes: optimisticState.likes + (optimisticState.liked ? -1 : 1),});startTransition(async () => {// Server action runs in backgroundawait toggleLike(postId);// If action fails, React reverts optimistic state automatically});}return (<button onClick={handleToggle} disabled={isPending}>{optimisticState.liked ? "β€οΈ" : "π€"} {optimisticState.likes}</button>);}
// β 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 postexport async function deletePost(postId: string) {const session = await auth(); // authenticatedif (!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 deleteconst 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' postsawait db.post.delete({ where: { id: postId } });revalidatePath("/blog");}
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.
server-only to Guard Sensitive Modules// lib/db.ts β database client with env secretsimport "server-only"; // β add this importimport { 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 managementimport "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 keyimport "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 }),});}
# .env.local# β No NEXT_PUBLIC_ prefix β server onlyDATABASE_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.
# .env.local# β NEXT_PUBLIC_ prefix β included in client bundle# Visible to anyone who inspects the JSNEXT_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_.
// β SECRET LEAKED β apiKey goes to the browserasync 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 (<UserCarduser={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 resultasync function Page() {// Use secret on the server β never prop-drill itconst user = await fetchUser(process.env.INTERNAL_API_KEY // used server-side β);// Pass only the data the client component needsreturn (<UserCardname={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 itreturn <Profile user={enrichedUser} />;}
// β Trusting params directly β SQL injection / chaosasync 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 directlyasync function SearchPage({ searchParams }) {const q = searchParams.q; // could be anythingconst results = await search(`%${q}%`); // SQL injection!}
import { z } from "zod";import { notFound } from "next/navigation";// β Validate params at the top of every page/layoutconst 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 tooconst 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 integerconst results = await search(q, page);}
// Opt-in in next.config.ts// experimental: { taint: true }import {experimental_taintObjectReference,experimental_taintUniqueValue,} from "react";// lib/user.tsexport async function getUser(id: string) {const user = await db.user.findUnique({ where: { id } });// Taint the entire object β can't be passed to CCexperimental_taintObjectReference("User data must not be passed to client components",user,);// Taint a specific sensitive valueexperimental_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.
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.
// β 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.
// β Runs on server β library doesn't ship to browserimport { codeToHtml } from "shiki";// Server Component β no "use client"async function CodeBlock({code,language,}: {code: string;language: string;}) {// shiki runs on server, produces static HTMLconst html = await codeToHtml(code, {lang: language,theme: "github-dark",});return (<divclassName="rounded-md overflow-auto"dangerouslySetInnerHTML={{ __html: html }}/>);}// Client bundle impact: 0 bytes β only HTML/CSS sent β
// β Lazy-load heavy Client Componentsimport dynamic from "next/dynamic";// Only loads when this component rendersconst RichTextEditor = dynamic(() => import("@/components/RichTextEditor"),{loading: () => <EditorSkeleton />,ssr: false, // β for browser-only libs (e.g. CodeMirror)},);// β Code-split the chart libraryconst 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 exportconst HeavyModal = dynamic(() => import("@/components/modals").then((m) => m.HeavyModal),{ loading: () => <ModalSkeleton /> },);// β Conditionally load β only when actually neededfunction 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.
// β Raw img tag β no optimization<imgsrc="/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<Imagesrc="/hero.jpg"alt="Hero image"width={1200}height={600}priority // β eager load for above-the-foldplaceholder="blur" // β low-res blur while loadingclassName="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)<Imagesrc={product.imageUrl}alt={product.name}width={400} height={400}// lazy loaded automatically β/>// Result: WebP/AVIF, right size per device, lazy, CLS-free
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 navigationimport { 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
// app/layout.tsx// β next/font β downloaded at build time, self-hostedimport { Inter, JetBrains_Mono } from "next/font/google";import localFont from "next/font/local";const inter = Inter({subsets: ["latin"],variable: "--font-sans", // CSS variable for Tailwinddisplay: "swap", // show fallback while loadingpreload: true,});const mono = JetBrains_Mono({subsets: ["latin"],variable: "--font-mono",weight: ["400", "500", "700"],});// Or a local fontconst customFont = localFont({src: "./fonts/CustomFont.woff2",variable: "--font-custom",});export default function RootLayout({ children }) {return (<htmllang="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
Type-safe RSC apps catch boundary violations, parameter mismatches, and data contract breaks at compile time β not at 2 AM in production.
PageProps and LayoutProps generics// app/products/[id]/page.tsx// β Explicit params + searchParams typingtype 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 typeexport async function generateStaticParams(): Promise<Array<{ id: string }>> {const products = await getTopProducts();return products.map((p) => ({ id: p.id }));}
// types/actions.ts β shared action state typeexport 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 β
// lib/types.ts β derive types from Prismaimport { Prisma } from "@prisma/client";// Full model typeexport type User = Prisma.UserGetPayload<{}>;// Subset type β only what the card needsexport type UserCardData = Prisma.UserGetPayload<{select: {id: true;name: true;avatar: true;role: true;};}>;// With relationsexport type PostWithAuthor = Prisma.PostGetPayload<{include: { author: { select: { name: true; avatar: true } } };}>;// Array variantexport type PostFeed = PostWithAuthor[];// β If Prisma schema changes, types update automatically.// No manual type maintenance.
// lib/queries.ts β typed return valuesimport { 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>;}
// β Not narrowing β TypeScript can't help youasync 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-nullasync 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} />);}
satisfies for Config Objects// β satisfies checks the type but preserves literal inferencetype NavItem = {label: string;href: string;icon: string;};// satisfies NavItem[] β TypeScript checks shape// but NAV_ITEMS retains full literal typesconst 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;
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 ComponentCatches runtime errors in the route segment. Receives error and reset(). Must be a Client Component ("use client").
not-found.tsxServer ComponentRenders when notFound() is called from within the segment. Use for missing resources β 404 semantics.
global-error.tsxClient ComponentCatches errors in the root layout. Rare edge case. Must render its own <html>/<body> tags since layout breaks.
loading.tsxServer ComponentNot an error handler but part of the resilience story β shows while the route suspends.
// app/products/error.tsx"use client"; // β Required β must be a Client Componentimport { 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 serviceconsole.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><buttononClick={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
// app/products/[id]/page.tsximport { 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 β 404if (!product) notFound();// Access control β redirect to login, not 404const 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 layoutexport 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"><Linkhref="/products"className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground">Browse all products</Link><Linkhref="/"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
// β 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 internalsconsole.error(err);return {success: false,message: "Failed to update profile. Please try again.",};}}
// β 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><buttononClick={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. β
Before shipping any feature, run through these categories. A green light across all nine means your RSC code is production-ready.
Every component starts as a Server Component. Add 'use client' only when you need real interactivity.
Push client boundaries as far toward the leaves as possible. Only the interactive fragment needs to be a CC.
Fetch data in the component that uses it. Use React.cache() to deduplicate. Avoid prop drilling via colocated queries.
Any component that might be slow gets its own Suspense boundary. Design skeletons before you design the real UI.
Every Server Action authenticates and authorises. Every module with secrets imports server-only. Props never carry secrets.
Know which cache layer applies to each piece of data. Tag everything. Revalidate after every mutation.
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.