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: true enabled in next.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" │ └─────────────┘ └─────────────┘
- 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
- useState for data that could be fetched server-side
- Duplicating fetch logic across components instead of colocating
- 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 async capability in deeply nested Server Components
Implementation
-
Isolate Interactive Logic → Extract into a Client Component
-
Create the "Hole" → Accept children as 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 async and 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
- 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
}
updateTag vs revalidateTag :
-
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
- 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> ); }
- 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>
- Debugging Tips
Check Cache Status
In development, Next.js logs cache hits/misses. Look for:
-
CACHE HIT
-
Served from cache
-
CACHE MISS
-
Generated fresh and cached
-
CACHE 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)
Related Documentation
-
Cache Components Guide
-
use cache Directive
-
cacheLife Function
-
cacheTag Function