nextjs-performance-architecture
This document aggregates three core architectural patterns for modern Next.js 16+ development: Data Fetching Colocation, The Donut Pattern, and Cache Components with use cache. These patterns are designed to improve performance, maintainability, and code composition.
Prerequisites: Next.js 16+ with
cacheComponents: trueenabled innext.config.ts.
Quick Decision Guide
Use this flowchart to choose the right pattern:
┌─────────────────────────────────────────────────────────────────┐
│ Component Rendering Decision │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Does it need user state, │
│ event handlers, or hooks? │
└─────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌────────────┐ ┌─────────────────────┐
│ "use │ │ Keep as Server │
│ client" │ │ Component │
└────────────┘ └─────────────────────┘
│
▼
┌─────────────────────────────┐
│ Does it fetch data or do │
│ expensive computation? │
└─────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌──────────────────────┐ Static in shell
│ Is data user/request │ (automatic)
│ specific? │
└──────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Wrap in │ │ Add │
│ <Suspense> │ │ "use cache" │
└─────────────┘ └─────────────┘
1. Data Fetching Colocation
When to Use
- Data is passed through multiple layers of components (prop drilling)
- Root layout/page is blocked by a large initial data fetch
- Components are not reusable because they depend on props from a specific parent
Implementation
Move async fetch calls directly into the Server Component that consumes the data:
// ❌ Before: Prop drilling blocks parallelism
export default async function Page() {
const data = await getData();
return <Child data={data} />;
}
// ✅ After: Collocated fetching enables parallel loading
export default async function Child() {
const data = await getData();
return <div>{data.title}</div>;
}
Resolving Promises with use()
Pass promises directly to Client Components and unwrap with React.use():
// Server Component
export default function Page() {
const userPromise = getUser(); // Don't await!
return (
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// Client Component
"use client";
import { use } from "react";
export function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return <div>{user.name}</div>;
}
❌ Anti-Patterns
- Fetching all data at page level and threading through props
- Using
useEffect+useStatefor data that could be fetched server-side - Duplicating fetch logic across components instead of colocating
2. The Donut Pattern
When to Use
- Adding interactivity to a page section while keeping nested content server-rendered
- Avoiding
"use client"on a large component tree for a small interactive element - Preserving
asynccapability in deeply nested Server Components
Implementation
- Isolate Interactive Logic → Extract into a Client Component
- Create the "Hole" → Accept
childrenas a prop - Compose on Server → Pass Server Components as children
// AnimatedContainer.tsx (Client Component - the "donut")
"use client";
import { motion } from "framer-motion";
export function AnimatedContainer({ children }: { children: React.ReactNode }) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{children}
</motion.div>
);
}
// Page.tsx (Server Component)
import { AnimatedContainer } from "./AnimatedContainer";
import { ProductList } from "./ProductList"; // Server Component
export default function Page() {
return (
<AnimatedContainer>
{/* ProductList runs on server, not included in client bundle */}
<ProductList />
</AnimatedContainer>
);
}
Benefits
- Reduced Bundle Size: Server Component code stays on server
- Async Support: Inner components can still be
asyncand fetch data - Animation/Interactivity: Outer wrapper handles client-side concerns
❌ Anti-Patterns
- Marking entire page as
"use client"to add one click handler - Putting data fetching in Client Components when it could be server-side
- Nesting Client Components unnecessarily deep
3. Cache Components with use cache
Cache Components let you mix static, cached, and dynamic content in a single route—the speed of static sites with the flexibility of dynamic rendering.
Setup
Enable in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
How It Works
At build time, Next.js renders your route. Components that don't access network resources or request data are automatically static. For others, you choose:
| Scenario | Solution |
|---|---|
| Needs request data (cookies, headers, user-specific) | Wrap in <Suspense> |
| Expensive but static/shared | Add "use cache" |
| Mix of both | Combine patterns (Donut + Cache) |
Basic Usage
// File-level caching (all exports cached)
"use cache";
export async function getProducts() {
return await db.product.findMany();
}
export async function getCategories() {
return await db.category.findMany();
}
// Function-level caching
export async function getFeaturedSkills() {
"use cache";
return await db.skill.findMany({ where: { featured: true } });
}
Cache Lifetime with cacheLife()
Control how long cached content lives using preset profiles:
| Profile | Use Case | Stale | Revalidate | Expire |
|---|---|---|---|---|
"seconds" |
Real-time data (stock prices) | 0s | 1s | 60s |
"minutes" |
Frequently updated (feeds) | 5min | 1min | 1h |
"hours" |
Moderately static (blog posts) | 5min | 1h | 1d |
"days" |
Rarely changing (product catalog) | 5min | 1d | 1w |
"weeks" |
Very stable (landing pages) | 5min | 1w | 1mo |
"max" |
Immutable (versioned assets) | 5min | 1y | indefinite |
import { cacheLife } from "next/cache";
export async function ProductCatalog() {
"use cache";
cacheLife("days"); // Cache for ~1 day
const products = await db.product.findMany();
return <ProductGrid products={products} />;
}
Conditional Cache Lifetimes
import { cacheLife, cacheTag } from "next/cache";
async function getPostContent(slug: string) {
"use cache";
cacheTag(`post-${slug}`);
const post = await fetchPost(slug);
if (!post) {
cacheLife("minutes"); // Missing content, check again soon
return null;
}
cacheLife("days"); // Published content, cache longer
return post.data;
}
Cache Invalidation with cacheTag()
Tag cached entries for on-demand invalidation:
import { cacheTag } from "next/cache";
export async function getSkillById(id: string) {
"use cache";
cacheTag("skills", `skill-${id}`);
return await db.skill.findUnique({ where: { id } });
}
Invalidate with updateTag() in Server Actions (preferred for immediate invalidation):
"use server";
import { updateTag } from "next/cache";
export async function updateSkill(id: string, data: SkillData) {
await db.skill.update({ where: { id }, data });
updateTag(`skill-${id}`); // Invalidate specific skill immediately
updateTag("skills"); // Invalidate all skills
}
updateTagvsrevalidateTag:
updateTag— Use in Server Actions for read-your-own-writes (user sees changes immediately)revalidateTag— Use in Route Handlers, webhooks, or when stale-while-revalidate is acceptable
❌ Anti-Patterns
| Don't | Do Instead |
|---|---|
export const revalidate = 3600 |
cacheLife("hours") inside "use cache" |
export const dynamic = "force-static" |
Add "use cache" to component |
export const fetchCache = "force-cache" |
Use "use cache" to control caching |
| Reading cookies/headers inside cached scope | Read outside, pass as arguments |
4. Combined Patterns
The real power comes from combining all three patterns:
Example: E-commerce Product Page
// app/products/[id]/page.tsx (Server Component)
import { Suspense } from "react";
import { ProductDetails } from "@/components/ProductDetails";
import { AddToCartButton } from "@/components/AddToCartButton";
import { RecommendedProducts } from "@/components/RecommendedProducts";
import { ProductSkeleton, RecommendedSkeleton } from "@/components/skeletons";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="grid grid-cols-3 gap-6">
{/* Cached: Product details rarely change */}
<div className="col-span-2">
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={id} />
</Suspense>
</div>
{/* Dynamic: User-specific cart state (Donut Pattern) */}
<aside>
<AddToCartButton productId={id} />
</aside>
{/* Cached with Suspense: Recommendations can stream in */}
<div className="col-span-3">
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedProducts productId={id} />
</Suspense>
</div>
</div>
);
}
// components/ProductDetails.tsx (Cached Server Component)
import { cacheLife, cacheTag } from "next/cache";
export async function ProductDetails({ id }: { id: string }) {
"use cache";
cacheLife("hours");
cacheTag("products", `product-${id}`);
const product = await db.product.findUnique({ where: { id } });
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span className="text-2xl font-bold">${product.price}</span>
</article>
);
}
// components/AddToCartButton.tsx (Client Component - Donut wrapper)
"use client";
import { useState, useTransition } from "react";
import { addToCart } from "@/actions/cart";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
const [quantity, setQuantity] = useState(1);
return (
<form
action={() => startTransition(() => addToCart(productId, quantity))}
>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<button disabled={isPending}>
{isPending ? "Adding..." : "Add to Cart"}
</button>
</form>
);
}
5. Suspense Boundaries Best Practices
When to Use Suspense
| Scenario | Suspense Needed? |
|---|---|
Cached component ("use cache") |
Usually not needed (part of static shell) |
| Dynamic data (user-specific) | Yes - shows fallback while loading |
| Streaming async Server Component | Yes - prevents blocking |
Client Component with use() |
Yes - parent must provide boundary |
Granular vs. Coarse Boundaries
// ❌ Coarse: Entire page waits for all data
<Suspense fallback={<FullPageSkeleton />}>
<Header />
<MainContent />
<Sidebar />
</Suspense>
// ✅ Granular: Components load independently
<Header /> {/* Static, no Suspense */}
<Suspense fallback={<ContentSkeleton />}>
<MainContent /> {/* Async */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Async, loads parallel to MainContent */}
</Suspense>
6. Debugging Tips
Check Cache Status
In development, Next.js logs cache hits/misses. Look for:
CACHE HIT- Served from cacheCACHE MISS- Generated fresh and cachedCACHE SKIP- Not cacheable (dynamic data accessed)
Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Component not caching | Accessing request-specific data | Move cookies/headers read outside cached scope |
| Stale data after mutation | Missing revalidateTag call |
Add proper cache tags and revalidate |
| Hydration mismatch | Date/time in cached component | Use cacheLife("seconds") or move to client |
Build error with use cache |
Edge runtime not supported | Use Node.js runtime only |
Verify Static Shell
Run next build and check the output:
○= Static (rendered at build time)●= SSG with dynamic paramsƒ= Dynamic (rendered at request time)◐= Partial Prerendering (static shell + dynamic holes)
Quick Reference
| Pattern | Purpose | Key Directive |
|---|---|---|
| Data Colocation | Fetch where data is used | None (architectural) |
| Donut Pattern | Server content in Client wrapper | "use client" on wrapper only |
| Cache Components | Cache expensive computations | "use cache" |
| Function | Purpose |
|---|---|
cacheLife(profile) |
Set cache duration |
cacheTag(...tags) |
Tag for targeted invalidation |
updateTag(tag) |
Invalidate immediately (Server Actions only) |
revalidateTag(tag) |
Invalidate with stale-while-revalidate (Route Handlers, webhooks) |