Next.js 15 App Router Deep Dive: Server Components, Caching, and Performance in Production
on Next.js, React, Frontend, Performance, Web development, Typescript
Next.js 15 App Router Deep Dive: Server Components, Caching, and Performance in Production
Next.js 15 shipped with enough changes to the caching model that most “Next.js 13/14” articles on the internet are now actively misleading. The App Router is mature, but it has opinions. This post covers what actually matters for production apps in 2026.
Photo by Gabriel Heinzer on Unsplash
The Mental Model Shift
The Pages Router was simple: every file in /pages is a route, getServerSideProps runs on the server, getStaticProps builds static pages. Mental model: pages are functions.
The App Router’s mental model is: components are rendering targets. A component can render on the server, on the client, or be pre-rendered at build time — and these can be mixed in the same route.
app/
├── layout.tsx ← Server Component (default)
├── page.tsx ← Server Component (default)
├── dashboard/
│ ├── page.tsx ← Server Component
│ └── chart.tsx ← Client Component ('use client')
└── api/
└── route.ts ← Route Handler
Server Components: What They’re Good For
Server Components render on the server and send HTML (or React Server Component payload) to the client. They never ship their code to the browser.
// app/products/page.tsx - Server Component
import { db } from '@/lib/db';
export default async function ProductsPage() {
// This query runs on the server — no API route needed
const products = await db.query.products.findMany({
where: (products, { eq }) => eq(products.active, true),
orderBy: (products, { desc }) => [desc(products.createdAt)],
limit: 20,
});
return (
<main>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</main>
);
}
No useEffect, no loading state, no API route to maintain. The component just fetches data and renders. This is the power of Server Components.
What Server Components are great for:
- Database queries
- Accessing environment variables and secrets
- Rendering components that don’t need interactivity
- Large dependencies (like syntax highlighters) that shouldn’t ship to the client
What they can’t do:
useState,useEffect, or any React hooks- Browser APIs (
window,document,localStorage) - Event handlers (
onClick,onChange) - Context consumers (only context providers from client land can be consumed)
The 'use client' Boundary
Mark a component 'use client' when it needs interactivity. Everything imported by a Client Component also becomes a client-side module.
// components/add-to-cart.tsx
'use client';
import { useState } from 'react';
import { addToCart } from '@/lib/cart-actions';
export function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const [added, setAdded] = useState(false);
async function handleClick() {
setLoading(true);
await addToCart(productId);
setAdded(true);
setLoading(false);
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Adding...' : added ? 'Added ✓' : 'Add to Cart'}
</button>
);
}
Common mistake: Making the entire page a Client Component because one child needs interactivity. Instead, keep the parent as a Server Component and pass only what the client needs as props.
// ✅ Correct: Server Component wraps Client Component
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // runs on server
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Only this button is a Client Component */}
<AddToCartButton productId={product.id} />
</div>
);
}
Server Actions: Forms Without API Routes
Server Actions let you call server-side functions directly from Client Components or forms, without writing an API route.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';
const CreateProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.coerce.number().positive(),
description: z.string().optional(),
});
export async function createProduct(formData: FormData) {
const validated = CreateProductSchema.safeParse({
name: formData.get('name'),
price: formData.get('price'),
description: formData.get('description'),
});
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
await db.insert(products).values(validated.data);
revalidatePath('/products');
return { success: true };
}
// app/products/new/page.tsx
import { createProduct } from '@/app/actions';
export default function NewProductPage() {
return (
<form action={createProduct}>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" placeholder="Price" required />
<textarea name="description" placeholder="Description" />
<button type="submit">Create Product</button>
</form>
);
}
This works even with JavaScript disabled — it degrades to a standard HTML form POST. For progressive enhancement with loading states, use useActionState:
'use client';
import { useActionState } from 'react';
import { createProduct } from '@/app/actions';
export function CreateProductForm() {
const [state, action, isPending] = useActionState(createProduct, null);
return (
<form action={action}>
{state?.error && <p className="error">{JSON.stringify(state.error)}</p>}
<input name="name" placeholder="Product name" />
<input name="price" type="number" placeholder="Price" />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Product'}
</button>
</form>
);
}
Caching in Next.js 15: The Complete Picture
Next.js 15 made fetch caching opt-in instead of opt-out — a breaking change from 14. This is the source of most migration confusion.
The Four Cache Layers
Request → Router Cache (client) → Full Route Cache (static)
→ Data Cache (fetch) → Server (database/API)
1. Router Cache (client-side)
- In-memory cache in the browser
- Caches RSC payloads for navigated routes
- Duration: 30s for dynamic, 5min for static
- Invalidated by
router.refresh()or Server Action
2. Full Route Cache (build time)
- HTML + RSC payload cached at build time
- Only for static routes (no dynamic functions)
- Invalidated by
revalidatePath/revalidateTag
3. Data Cache (fetch)
- Persistent cache on the server
- In Next.js 15: NOT cached by default
- Opt in with
{ cache: 'force-cache' }or{ next: { revalidate: 60 } }
4. Request Memoization
- Deduplicates identical fetch calls within a single render tree
- Automatic, can’t be controlled
Data Fetching Patterns
// Not cached (default in Next.js 15) — always fresh
const data = await fetch('https://api.example.com/data');
// Cached until manually invalidated
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
});
// Revalidate every 60 seconds (ISR-style)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
});
// Tagged for on-demand revalidation
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] },
});
// Later, in a Server Action:
revalidateTag('products'); // invalidates all fetches tagged 'products'
Route Segment Configuration
Control caching at the route level:
// app/products/page.tsx
export const dynamic = 'force-dynamic'; // never cache
export const revalidate = 60; // revalidate every 60s
export const fetchCache = 'force-cache'; // default all fetches to cached
Streaming and Suspense
Streaming lets you send HTML to the client progressively — fast content first, slow content as it resolves.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from './revenue-chart';
import { LatestInvoices } from './latest-invoices';
import { CardSkeleton } from '@/components/skeletons';
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* This renders immediately */}
<p>Welcome back!</p>
{/* These stream in as their data resolves */}
<Suspense fallback={<CardSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<LatestInvoices />
</Suspense>
</main>
);
}
// revenue-chart.tsx - fetches its own data
async function RevenueChart() {
// This request runs in parallel with LatestInvoices
const revenue = await getRevenueData(); // slow DB query
return <Chart data={revenue} />;
}
The key insight: each Suspense boundary is independent. RevenueChart and LatestInvoices fetch in parallel. The page doesn’t wait for both — it streams each as it completes.
Parallel Routes and Intercepting Routes
Parallel Routes let you render multiple pages in the same layout simultaneously.
app/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
└── layout.tsx ← receives both as props
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3">
<div>{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
);
}
Intercepting Routes let you show a modal when navigating client-side but a full page when accessing the URL directly — perfect for photo modals, login overlays, etc.
app/
├── photos/
│ └── [id]/
│ └── page.tsx ← Full photo page (direct URL access)
└── @modal/
└── (.)photos/
└── [id]/
└── page.tsx ← Modal (client-side navigation)
Performance Checklist
Bundle size:
- Use
next/dynamicfor large Client Components that aren’t needed on initial load. - Check your bundle with
@next/bundle-analyzer. - Move heavy computation to Server Components.
Images:
- Always use
next/image. Set explicitwidthandheightor usefillwith a sized container. - Use
priorityon above-the-fold images.
Fonts:
- Use
next/font— it downloads fonts at build time and serves them from your domain, eliminating the render-blocking font request.
Prefetching:
<Link>prefetches by default in production. Disable withprefetch={false}for links rarely clicked.
Middleware:
- Keep middleware lean. It runs on every request and can’t use Node.js APIs (edge runtime only).
Common Gotchas
- Fetching in loops: Don’t
awaitfetches inside aforloop — they’ll run sequentially. UsePromise.all. - Prop serialization: Props passed from Server to Client Components must be serializable. No functions, no class instances.
- Context in Server Components: You can’t use
useContextin a Server Component. If you need global state on the server, use cookies or headers. - Environment variables: Prefix with
NEXT_PUBLIC_to expose to the browser. Without the prefix, they’re server-only. cookies()makes routes dynamic: Callingcookies()orheaders()in a Server Component opts out of static rendering.
Next.js 15’s App Router is genuinely powerful once the mental model clicks. The key insight: most of your components should be Server Components by default, and you selectively add 'use client' at the leaves of the component tree where interactivity is needed. Everything else follows from there.
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
