- Published on
Next.js 15 + React 19 Complete Guide: Server Components to Server Actions — 2025 Production Handbook
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. React 19 Innovations
- 2. Next.js 15 Core Changes
- 3. Server Components Deep Dive
- 4. Server Actions in Practice
- 5. Advanced App Router Patterns
- 6. Caching Strategy Mastery
- 7. Performance Optimization
- 8. Production Project: Full-Stack Dashboard
- 9. Pages Router to App Router Migration
- 10. Quiz
- 11. References
Introduction
The combination of React 19 and Next.js 15 is fundamentally reshaping frontend development. Server Components are now the default, Server Actions eliminate the API layer, Turbopack revolutionizes build speed, and PPR breaks down the boundary between static and dynamic rendering.
This guide is a complete production handbook for developers transitioning from the Pages Router era of Next.js, or those who have only worked with React 18. It goes beyond conceptual explanations to cover real code patterns and migration strategies.
1. React 19 Innovations
React 19 was released as a stable version in December 2024, representing a major update that evolves React's fundamental philosophy.
1.1 React Compiler (Automatic Memoization)
The React Compiler (formerly React Forget) eliminates the need to manually write useMemo, useCallback, and memo. The compiler analyzes components at build time and automatically applies memoization.
Before (React 18):
import { useMemo, useCallback, memo } from 'react'
const ExpensiveList = memo(({ items, onSelect }: Props) => {
const sortedItems = useMemo(() => items.sort((a, b) => a.name.localeCompare(b.name)), [items])
const handleClick = useCallback(
(id: string) => {
onSelect(id)
},
[onSelect]
)
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
)
})
After (React 19 + Compiler):
// React Compiler automatically applies memoization
// No need for useMemo, useCallback, or memo
function ExpensiveList({ items, onSelect }: Props) {
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name))
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
)
}
To enable the React Compiler in Next.js 15:
// next.config.js
module.exports = {
experimental: {
reactCompiler: true,
},
}
Installation is also required:
npm install babel-plugin-react-compiler
1.2 use() Hook — Reading Promises and Context
The use() hook in React 19 differs from traditional hooks in that it can be called inside conditionals. It reads Promises directly with Suspense integration and consumes Context in a new way.
import { use, Suspense } from 'react'
// Reading a Promise directly with use()
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Automatically suspends at Suspense boundary
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
// Conditionally reading Context
function ThemeButton({ showIcon }: { showIcon: boolean }) {
if (showIcon) {
const theme = use(ThemeContext)
return <Icon color={theme.primary} />
}
return <button>Click me</button>
}
// Usage
function App() {
const userPromise = fetchUser(userId) // Create Promise
return (
<Suspense fallback={<Loading />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
1.3 Actions: useActionState, useFormStatus, useOptimistic
React 19 introduces a dedicated set of hooks for forms and asynchronous operations.
useActionState — manages form action state:
'use client'
import { useActionState } from 'react'
import { createTodo } from '@/actions/todo'
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, {
message: '',
errors: {},
})
return (
<form action={formAction}>
<input name="title" placeholder="Enter a todo" />
{state.errors?.title && <p className="error">{state.errors.title}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
{state.message && <p>{state.message}</p>}
</form>
)
}
useFormStatus — reads the parent form's submission status from child components:
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending, data, method } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
useOptimistic — handles optimistic updates declaratively:
'use client'
import { useOptimistic } from 'react'
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
)
async function handleAdd(formData: FormData) {
const title = formData.get('title') as string
const tempTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(tempTodo) // Immediately update UI
await createTodoAction(formData) // Actually save to server
}
return (
<div>
<form action={handleAdd}>
<input name="title" />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
)
}
1.4 Document Metadata and Stylesheet Support
React 19 allows declaring title, meta, and link tags anywhere in the component tree:
function BlogPost({ post }: { post: Post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.summary} />
<link rel="stylesheet" href="/styles/blog.css" precedence="default" />
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
The precedence attribute on stylesheets controls CSS load order, and integration with Suspense allows deferring content rendering until the stylesheet is loaded.
1.5 Ref as Prop (No More forwardRef)
In React 19, function components can receive ref as a regular prop:
// Before (React 18) — forwardRef required
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />
})
// After (React 19) — ref is just a prop
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />
}
2. Next.js 15 Core Changes
Next.js 15 was released in October 2024 as the first major framework version to officially support React 19.
2.1 Turbopack Stabilization
Turbopack has reached stable status via next dev --turbopack. This Rust-based incremental build system delivers:
- Local server startup: Up to 76.7% faster
- Fast Refresh: Up to 96.3% faster
- Initial route compilation: Up to 45.8% faster (without cache)
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build"
}
}
Production build (next build) Turbopack support is still experimental, available via the --turbopack flag.
2.2 Async Request APIs
This is the biggest breaking change in Next.js 15. All APIs that access runtime request information are now asynchronous:
// Before (Next.js 14)
import { cookies, headers } from 'next/headers'
export default function Page({ params, searchParams }: PageProps) {
const cookieStore = cookies()
const headersList = headers()
const slug = params.slug
const query = searchParams.q
// ...
}
// After (Next.js 15) — everything requires await
import { cookies, headers } from 'next/headers'
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ q: string }>
}) {
const cookieStore = await cookies()
const headersList = await headers()
const { slug } = await params
const { q } = await searchParams
// ...
}
This change enables server rendering optimization. Next.js can now defer request data access until the actual point of use, pre-rendering static parts while processing only dynamic parts at request time.
Automatic migration codemod:
npx @next/codemod@canary next-async-request-api .
2.3 PPR (Partial Pre-Rendering)
PPR is an innovative rendering strategy that handles both static and dynamic parts within a single page:
// next.config.js
module.exports = {
experimental: {
ppr: 'incremental', // Gradually apply per route
},
}
// app/product/[id]/page.tsx
import { Suspense } from 'react'
export const experimental_ppr = true // Enable PPR for this route
// Static shell: pre-rendered at build time
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id) // Fetched at build time
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<StaticImage src={product.image} />
{/* Dynamic parts: streamed at request time */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<DynamicReviews productId={id} />
</Suspense>
</div>
)
}
// Dynamic component — automatically dynamic due to cookies/headers usage
async function DynamicPrice({ productId }: { productId: string }) {
const cookieStore = await cookies()
const region = cookieStore.get('region')?.value ?? 'US'
const price = await getPrice(productId, region)
return <PriceDisplay price={price} currency={region} />
}
How PPR works:
- Generate the static shell (HTML) at build time
- Mark dynamic parts with Suspense boundaries including fallbacks
- On request, immediately send the static shell and stream dynamic parts
2.4 next/after API
A new API that lets you execute asynchronous work after the response has been sent:
import { after } from 'next/server'
import { log } from '@/lib/logger'
export default async function Page() {
const data = await fetchData()
// Runs after response — no user-facing latency
after(() => {
log('page-view', { data: data.id })
})
return <Dashboard data={data} />
}
Ideal for logging, analytics, cache warming, and other tasks that should not affect user response time.
2.5 Caching Default Changes
This is the most important philosophical change in Next.js 15:
| Item | Next.js 14 | Next.js 15 |
|---|---|---|
| fetch cache | force-cache (cached by default) | no-store (uncached by default) |
| GET Route Handler | Cached | Not cached |
| Client Router Cache | 5 minutes | 0 seconds (always fresh on navigation) |
// Next.js 15 — explicit cache configuration recommended
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // Explicitly enable caching
next: { revalidate: 3600 }, // Revalidate after 1 hour
})
3. Server Components Deep Dive
3.1 How RSC Works
The core of React Server Components is the RSC Payload (also known as the Flight Protocol). This serialization format generated on the server conveys the component tree to the client.
The rendering flow works as follows:
- Server renders the RSC tree to produce the RSC Payload
- Includes references (bundle paths) for Client Components
- Client receives the RSC Payload and constructs the DOM tree
- Only Client Components undergo hydration
[Server] [Client]
| |
|-- RSC Payload (stream) -->|
| - Serialized Server |
| Components |-- DOM construction
| - Client references |-- Client hydration
| - Suspense boundaries |
| |
3.2 Server vs Client Component Boundary
What Server Components can do:
- Direct database access
- File system reading
- Use server-only libraries (fs, crypto, ORMs, etc.)
- Access server-only environment variables
- Zero impact on bundle size
When Client Components are needed:
- React hooks like useState, useEffect
- Browser APIs (localStorage, window, etc.)
- Event listeners (onClick, onChange, etc.)
- Class components
- Third-party libraries (most are client-only)
// app/dashboard/page.tsx (Server Component — default)
import { db } from '@/lib/db'
import { DashboardChart } from './chart' // Client Component
export default async function DashboardPage() {
// Direct DB access on server — no API layer needed
const metrics = await db.metrics.findMany({
where: { date: { gte: thirtyDaysAgo() } },
orderBy: { date: 'asc' },
})
// Only serializable data passed to Client Components
return (
<div>
<h1>Dashboard</h1>
<DashboardChart data={metrics} /> {/* Client Component */}
</div>
)
}
// app/dashboard/chart.tsx (Client Component)
'use client'
import { LineChart, Line, XAxis, YAxis } from 'recharts'
export function DashboardChart({ data }: { data: Metric[] }) {
// Interactive chart running only on the client
return (
<LineChart width={800} height={400} data={data}>
<XAxis dataKey="date" />
<YAxis />
<Line type="monotone" dataKey="value" stroke="#8884d8" />
</LineChart>
)
}
3.3 Composition Pattern: Server Wrapping Client
Having Server Components act as parents of Client Components is the core RSC pattern. Conversely, importing a Server Component inside a Client Component would automatically make it a Client Component. Instead, use the children pattern:
// app/layout.tsx (Server Component)
import { Sidebar } from './sidebar' // Client Component
import { UserInfo } from './user-info' // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
{/* Pass Server Component as children to Client Component */}
<Sidebar>
<UserInfo /> {/* This remains a Server Component */}
</Sidebar>
<main>{children}</main>
</div>
)
}
// app/sidebar.tsx (Client Component)
'use client'
import { useState } from 'react'
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true)
return (
<aside className={isOpen ? 'w-64' : 'w-16'}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* Children rendered on the server */}
</aside>
)
}
3.4 Direct DB Access and API Layer Elimination
One of the most powerful benefits of Server Components is accessing the database directly without separate API endpoints:
// Before (Pages Router + API Routes)
// 1. pages/api/posts.ts — API endpoint
// 2. lib/fetcher.ts — fetch utility
// 3. pages/blog.tsx — getServerSideProps + client component
// After (App Router + Server Components)
// A single file is enough
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { cache } from 'react'
// React cache prevents duplicates within the same request
const getPosts = cache(async () => {
return prisma.post.findMany({
include: { author: true, tags: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
})
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.author.name}</p>
<time>{post.createdAt.toLocaleDateString()}</time>
</article>
))}
</div>
)
}
3.5 Bundle Size Optimization
Libraries used in Server Components are never included in the client bundle:
// Server Component — these imports are NOT included in client bundle
import { marked } from 'marked' // 35KB
import hljs from 'highlight.js' // 180KB
import { parse } from 'yaml' // 25KB
import sanitizeHtml from 'sanitize-html' // 60KB
// 300KB total of libraries never sent to the client!
export default async function MarkdownPage({ slug }: { slug: string }) {
const raw = await fs.readFile(`content/${slug}.md`, 'utf-8')
const html = marked(raw, {
highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
})
const safe = sanitizeHtml(html)
return <div dangerouslySetInnerHTML={{ __html: safe }} />
}
4. Server Actions in Practice
Server Actions are defined with the 'use server' directive, enabling an RPC pattern where clients directly call server functions.
4.1 Form Handling: useActionState + Progressive Enhancement
// actions/auth.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createSession } from '@/lib/session'
const loginSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type LoginState = {
message: string
errors: Record<string, string[]>
}
export async function loginAction(prevState: LoginState, formData: FormData): Promise<LoginState> {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
}
const validated = loginSchema.safeParse(rawData)
if (!validated.success) {
return {
message: '',
errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
}
}
const user = await authenticateUser(validated.data)
if (!user) {
return {
message: 'Invalid email or password',
errors: {},
}
}
await createSession(user.id)
redirect('/dashboard')
}
// app/login/page.tsx
'use client'
import { useActionState } from 'react'
import { loginAction } from '@/actions/auth'
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(loginAction, {
message: '',
errors: {},
})
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
{state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
{state.errors?.password && <p className="text-red-500">{state.errors.password[0]}</p>}
</div>
{state.message && <p className="text-red-500">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Signing in...' : 'Sign In'}
</button>
</form>
)
}
This form supports Progressive Enhancement — it works even when JavaScript is disabled, relying on native HTML form behavior.
4.2 Optimistic Updates in Practice
// app/comments/comment-section.tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addComment } from '@/actions/comments'
type Comment = {
id: string
text: string
author: string
createdAt: string
pending?: boolean
}
export function CommentSection({
postId,
initialComments,
}: {
postId: string
initialComments: Comment[]
}) {
const formRef = useRef<HTMLFormElement>(null)
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, newComment]
)
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string
// Immediately reflect in UI (optimistic)
addOptimisticComment({
id: `temp-${Date.now()}`,
text,
author: 'Me',
createdAt: new Date().toISOString(),
pending: true,
})
formRef.current?.reset()
// Actually save to server
await addComment(postId, formData)
}
return (
<div>
<ul className="space-y-4">
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
<strong>{comment.author}</strong>
<p>{comment.text}</p>
{comment.pending && <span>Sending...</span>}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<textarea name="text" placeholder="Write a comment" required />
<button type="submit">Post Comment</button>
</form>
</div>
)
}
4.3 File Upload
// actions/upload.ts
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { revalidatePath } from 'next/cache'
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
if (!file || file.size === 0) {
return { error: 'Please select a file' }
}
// File size limit (5MB)
if (file.size > 5 * 1024 * 1024) {
return { error: 'File size must be 5MB or less' }
}
// Check allowed types
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { error: 'Only JPEG, PNG, and WebP images are allowed' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const uniqueName = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public/uploads', uniqueName)
await writeFile(path, buffer)
revalidatePath('/gallery')
return { success: true, path: `/uploads/${uniqueName}` }
}
4.4 revalidatePath and revalidateTag
// actions/post.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
await db.post.update({
where: { id },
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
// Revalidate specific path
revalidatePath(`/blog/${id}`)
// Tag-based revalidation (finer control)
revalidateTag('posts')
revalidateTag(`post-${id}`)
}
// Specify tags when fetching data
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: [`post-${id}`, 'posts'] },
})
return res.json()
}
4.5 Server Actions vs API Routes Comparison
| Feature | Server Actions | Route Handlers (API Routes) |
|---|---|---|
| Use pattern | Form submissions, data mutations | External APIs, webhooks, third-party integrations |
| Progressive Enhancement | Supported (works without JS) | Not supported |
| Type safety | Guaranteed by function signature | Manual type definition required |
| Invocation | form action or direct call | fetch/HTTP request |
| Caching | Automatic revalidation integration | Manual cache configuration |
| Security | Automatic CSRF protection | Manual implementation required |
| Use case | Internal data mutations | External system integrations |
5. Advanced App Router Patterns
5.1 Parallel Routes (@slot)
Render multiple pages simultaneously within a single layout:
app/
dashboard/
@analytics/
page.tsx <- Analytics slot
loading.tsx <- Analytics loading state
@team/
page.tsx <- Team slot
loading.tsx <- Team loading state
layout.tsx <- Combines both slots
page.tsx <- Main content
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
)
}
Each slot loads independently — if one is slow, the others display first.
5.2 Intercepting Routes
Particularly useful for modal patterns. Shows another route within the current layout while preserving the URL:
app/
feed/
page.tsx <- Feed list
@modal/
(..)photo/[id]/
page.tsx <- View photo as modal
default.tsx
layout.tsx
photo/[id]/
page.tsx <- Full page photo view (direct access/refresh)
// app/feed/@modal/(..)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<img src={photo.url} alt={photo.alt} />
<p>{photo.description}</p>
</Modal>
)
}
Clicking a photo in the feed opens it as a modal, but the URL changes to /photo/123. Visiting this URL directly or refreshing renders the full-page version.
5.3 Route Groups
Logically group routes without affecting URL structure:
app/
(marketing)/ <- Not included in URL
layout.tsx <- Marketing-specific layout
page.tsx <- / path
about/
page.tsx <- /about
pricing/
page.tsx <- /pricing
(dashboard)/ <- Not included in URL
layout.tsx <- Dashboard-specific layout (sidebar etc.)
dashboard/
page.tsx <- /dashboard
settings/
page.tsx <- /settings
5.4 Loading, Error, Not-Found Boundaries
App Router file conventions automatically set up Suspense and Error Boundaries:
// app/blog/loading.tsx — Suspense fallback automatically applied
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
)
}
// app/blog/error.tsx — Error Boundary automatically applied
'use client' // Error components must be Client Components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="py-10 text-center">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset} className="mt-4 bg-blue-500 px-4 py-2 text-white">
Try again
</button>
</div>
)
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div className="py-20 text-center">
<h2 className="text-2xl font-bold">Post not found</h2>
<p className="mt-2 text-gray-600">The requested post does not exist.</p>
</div>
)
}
5.5 Dynamic SEO with generateMetadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
return { title: 'Post Not Found' }
}
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.summary,
images: [post.coverImage],
},
}
}
5.6 Middleware Usage
// middleware.ts (project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Auth check
const token = request.cookies.get('session-token')
if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
// i18n — redirect based on Accept-Language
const locale = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
if (request.nextUrl.pathname === '/' && locale === 'ko') {
return NextResponse.rewrite(new URL('/ko', request.url))
}
// Security headers
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
6. Caching Strategy Mastery
Next.js caching consists of 4 layers, and understanding each one's role is critical.
6.1 The Four Caching Layers
1. Request Memoization
Automatically deduplicates identical fetch requests within the same rendering cycle:
// Two components making the same call in one render -> only 1 actual request
async function Header() {
const user = await fetch('/api/user') // Request 1
return <h1>{user.name}</h1>
}
async function Sidebar() {
const user = await fetch('/api/user') // Memoized — no actual request
return <nav>{user.role}</nav>
}
2. Data Cache
Persistently stores fetch responses on the server (disabled by default in Next.js 15):
// Explicit cache configuration
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache', // Permanent cache
next: { revalidate: 3600 }, // Revalidate after 1 hour
next: { tags: ['products'] }, // Tag-based revalidation
})
3. Full Route Cache
Caches HTML and RSC Payload of static routes generated at build time:
// This page is cached at build time (static route)
export default async function AboutPage() {
return <div>About Us</div>
}
// Static generation for dynamic routes using generateStaticParams
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
4. Router Cache
Caches RSC Payload of visited routes in browser memory. Default is 0 seconds in Next.js 15 (always fresh data):
// Configure Router Cache in next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // Dynamic routes: 30 seconds
static: 180, // Static routes: 180 seconds
},
},
}
6.2 cacheLife and cacheTag (Next.js 15)
New caching APIs replacing the previous unstable_cache:
// next.config.js
module.exports = {
experimental: {
dynamicIO: true,
cacheLife: {
blog: {
stale: 300, // Serve from cache for 5 minutes
revalidate: 900, // Background revalidation every 15 minutes
expire: 86400, // Expire after 24 hours
},
frequent: {
stale: 0,
revalidate: 60,
expire: 300,
},
},
},
}
import { cacheLife, cacheTag } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('blog')
cacheTag('products')
return db.product.findMany()
}
6.3 Rendering Strategy Comparison
| Strategy | At Build | At Request | Revalidation | Use Case |
|---|---|---|---|---|
| SSG (Static) | Generate HTML | Serve from cache | On rebuild | Blog, docs |
| ISR | Generate HTML | Cache + background refresh | Time/tag-based | Product lists, news |
| SSR | — | Render per request | None | Personalized pages |
| PPR | Generate static shell | Render dynamic parts only | Mixed | Product details (price+description) |
6.4 Real-World: E-commerce Product Page Caching Strategy
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_cache } from 'next/cache'
// Product info: rarely changes, ISR (1 hour)
const getProductInfo = unstable_cache(
async (id: string) => {
return db.product.findUnique({
where: { id },
select: { name: true, description: true, images: true },
})
},
['product-info'],
{ revalidate: 3600, tags: ['products'] }
)
// Pricing/stock: changes frequently, short cache (1 minute)
const getProductPricing = unstable_cache(
async (id: string) => {
return db.product.findUnique({
where: { id },
select: { price: true, stock: true, discount: true },
})
},
['product-pricing'],
{ revalidate: 60, tags: ['pricing'] }
)
// Reviews: includes per-user data, dynamic
async function getProductReviews(id: string) {
return db.review.findMany({
where: { productId: id },
orderBy: { createdAt: 'desc' },
take: 10,
})
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProductInfo(id)
return (
<div>
<h1>{product?.name}</h1>
<p>{product?.description}</p>
<Suspense fallback={<div>Loading price...</div>}>
<PriceSection productId={id} />
</Suspense>
<Suspense fallback={<div>Loading reviews...</div>}>
<ReviewSection productId={id} />
</Suspense>
</div>
)
}
async function PriceSection({ productId }: { productId: string }) {
const pricing = await getProductPricing(productId)
return (
<div>
<span className="text-2xl font-bold">${pricing?.price}</span>
{pricing?.discount && <span className="text-red-500">{pricing.discount}% off</span>}
<p>{pricing?.stock ? `In stock: ${pricing.stock}` : 'Out of stock'}</p>
</div>
)
}
async function ReviewSection({ productId }: { productId: string }) {
const reviews = await getProductReviews(productId)
return (
<div>
<h2>Reviews ({reviews.length})</h2>
{reviews.map((review) => (
<div key={review.id}>
<strong>{review.author}</strong>
<p>{review.content}</p>
</div>
))}
</div>
)
}
7. Performance Optimization
7.1 Turbopack vs Webpack Benchmarks
Real-world project comparison in Next.js 15:
| Metric | Webpack | Turbopack | Improvement |
|---|---|---|---|
| Cold Start (dev) | 8.2s | 1.9s | 76.7% |
| Hot Module Replacement | 520ms | 19ms | 96.3% |
| Route compilation (first access) | 1.8s | 0.97s | 45.8% |
| Memory usage (2000+ modules) | 1.2GB | 650MB | 45.8% |
7.2 next/image, next/font, next/script
// Image optimization
import Image from 'next/image'
function ProductCard({ product }: { product: Product }) {
return (
<Image
src={product.image}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false} // Lazy load for below-the-fold images
/>
)
}
// Font optimization — prevents layout shift
import { Inter, Noto_Sans_KR } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-noto',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${notoSansKR.variable}`}>
<body>{children}</body>
</html>
)
}
// Script optimization
import Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
{/* afterInteractive: loads after page hydration (default) */}
<Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
{/* lazyOnload: loads during browser idle time */}
<Script src="https://chatbot.example.com/widget.js" strategy="lazyOnload" />
{/* worker: runs in Web Worker (experimental) */}
<Script src="https://heavy-lib.example.com/main.js" strategy="worker" />
</>
)
}
7.3 Bundle Analyzer + Tree Shaking
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// existing config
})
ANALYZE=true npm run build
Tree shaking optimization tips:
// Bad — import entire library
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')
// Good — import only what you need
import sortBy from 'lodash/sortBy'
const sorted = sortBy(items, 'name')
// Best — use in Server Components (not included in bundle)
// Full import is fine in Server Components
import _ from 'lodash'
7.4 Dynamic Imports and Lazy Loading
import dynamic from 'next/dynamic'
// Lazy load client-only components
const DynamicChart = dynamic(() => import('@/components/chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Skip server rendering
})
// Conditional loading
const DynamicModal = dynamic(() => import('@/components/modal'))
export default function Page() {
const [showModal, setShowModal] = useState(false)
return (
<div>
<DynamicChart data={data} />
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <DynamicModal onClose={() => setShowModal(false)} />}
</div>
)
}
7.5 Core Web Vitals Optimization
LCP (Largest Contentful Paint) optimization:
// Set priority on the largest content element
<Image src="/hero.jpg" alt="Hero" priority sizes="100vw" />
// Preload critical resources
<link rel="preload" href="/api/critical-data" as="fetch" crossOrigin="anonymous" />
CLS (Cumulative Layout Shift) optimization:
// Specify image dimensions (width/height)
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />
// Prevent FOIT with font swap
const font = Inter({ display: 'swap' })
// Set minimum height for dynamic content
<div style={{ minHeight: '200px' }}>
<Suspense fallback={<Skeleton height={200} />}>
<DynamicContent />
</Suspense>
</div>
INP (Interaction to Next Paint) optimization:
// Lower priority for heavy tasks with useTransition
'use client'
import { useTransition } from 'react'
function SearchResults() {
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value) // Immediate update (high priority)
startTransition(() => {
// Result filtering is low priority
setResults(filterLargeDataset(e.target.value))
})
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultList results={results} />}
</div>
)
}
8. Production Project: Full-Stack Dashboard
8.1 Project Structure
my-dashboard/
app/
(auth)/
login/page.tsx
register/page.tsx
layout.tsx
(dashboard)/
dashboard/
page.tsx
loading.tsx
error.tsx
settings/
page.tsx
layout.tsx
api/
webhooks/
route.ts
layout.tsx
actions/
auth.ts
dashboard.ts
lib/
prisma.ts
auth.ts
utils.ts
components/
ui/
charts/
forms/
prisma/
schema.prisma
8.2 Authentication: Auth.js v5 (NextAuth.js v5)
// lib/auth.ts
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google,
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.password) return null
const valid = await bcrypt.compare(credentials.password as string, user.password)
return valid ? user : null
},
}),
],
callbacks: {
authorized: async ({ auth: session }) => {
return !!session
},
session: ({ session, token }) => ({
...session,
user: { ...session.user, id: token.sub },
}),
},
pages: {
signIn: '/login',
},
})
// middleware.ts
export { auth as middleware } from '@/lib/auth'
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
8.3 Database: Prisma + PostgreSQL
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String?
image String?
accounts Account[]
sessions Session[]
dashboards Dashboard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Dashboard {
id String @id @default(cuid())
name String
userId String
user User @relation(fields: [userId], references: [id])
widgets Widget[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Widget {
id String @id @default(cuid())
type String
title String
config Json
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
position Int
}
8.4 State Management: TanStack Query + Server Components
// app/(dashboard)/dashboard/page.tsx (Server Component)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { DashboardClient } from './dashboard-client'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) return null
// Fetch initial data on the server
const dashboard = await prisma.dashboard.findFirst({
where: { userId: session.user.id },
include: { widgets: { orderBy: { position: 'asc' } } },
})
// Pass initial data to Client Component
return <DashboardClient initialData={dashboard} />
}
// app/(dashboard)/dashboard/dashboard-client.tsx
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateWidget } from '@/actions/dashboard'
export function DashboardClient({ initialData }: { initialData: Dashboard | null }) {
const queryClient = useQueryClient()
// Server data as initial value, then refresh on client
const { data: dashboard } = useQuery({
queryKey: ['dashboard'],
queryFn: () => fetch('/api/dashboard').then((r) => r.json()),
initialData,
staleTime: 60_000, // 1 minute
})
const mutation = useMutation({
mutationFn: updateWidget,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
},
})
return (
<div className="grid grid-cols-3 gap-4">
{dashboard?.widgets.map((widget) => (
<WidgetCard key={widget.id} widget={widget} onUpdate={(data) => mutation.mutate(data)} />
))}
</div>
)
}
8.5 Deployment: Vercel Edge Runtime
// app/api/fast-api/route.ts
export const runtime = 'edge' // Use Edge Runtime
export async function GET(request: Request) {
const url = new URL(request.url)
const query = url.searchParams.get('q')
// Runs at the nearest region — low latency
const data = await fetch(`https://api.example.com/search?q=${query}`, {
next: { revalidate: 60 },
})
return Response.json(await data.json())
}
{
"regions": ["icn1", "nrt1"],
"functions": {
"app/api/**": {
"memory": 1024,
"maxDuration": 30
}
}
}
9. Pages Router to App Router Migration
9.1 Gradual Migration Strategy
App Router and Pages Router can coexist. This enables a gradual migration strategy:
Phase 1: Layout Migration
Create app/layout.tsx (root layout)
Move pages/_app.tsx logic to app/layout.tsx
Phase 2: Start with Static Pages
pages/about.tsx -> app/about/page.tsx
pages/pricing.tsx -> app/pricing/page.tsx
Phase 3: Dynamic Route Migration
pages/blog/[slug].tsx -> app/blog/[slug]/page.tsx
getServerSideProps -> async Server Component
Phase 4: API Routes Cleanup
Internal calls -> Replace with Server Actions
External integrations -> app/api/route.ts (Route Handlers)
Phase 5: Optimization
Configure caching strategies
Apply Parallel Routes, Intercepting Routes
9.2 getServerSideProps to Server Components
// Before: pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug },
})
if (!post) {
return { notFound: true }
}
return {
props: { post: JSON.parse(JSON.stringify(post)) },
}
}
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
// After: app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await prisma.post.findUnique({
where: { slug },
})
if (!post) {
notFound() // Automatically renders not-found.tsx
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
9.3 getStaticProps to generateStaticParams
// Before: pages/products/[id].tsx
export async function getStaticPaths() {
const products = await prisma.product.findMany({ select: { id: true } })
return {
paths: products.map((p) => ({ params: { id: p.id } })),
fallback: 'blocking',
}
}
export async function getStaticProps({ params }) {
const product = await prisma.product.findUnique({
where: { id: params.id },
})
return {
props: { product: JSON.parse(JSON.stringify(product)) },
revalidate: 3600,
}
}
// After: app/products/[id]/page.tsx
import { prisma } from '@/lib/prisma'
export async function generateStaticParams() {
const products = await prisma.product.findMany({ select: { id: true } })
return products.map((p) => ({ id: p.id }))
}
// dynamicParams = true is default (equivalent to fallback: 'blocking')
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await prisma.product.findUnique({ where: { id } })
return (
<div>
<h1>{product?.name}</h1>
<p>{product?.description}</p>
</div>
)
}
9.4 API Routes to Route Handlers and Server Actions
// Before: pages/api/posts.ts
export default async function handler(req, res) {
if (req.method === 'GET') {
const posts = await prisma.post.findMany()
return res.json(posts)
}
if (req.method === 'POST') {
const post = await prisma.post.create({ data: req.body })
return res.status(201).json(post)
}
}
// After (for external integration): app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
const posts = await prisma.post.findMany()
return NextResponse.json(posts)
}
// For internal data mutations, use Server Actions instead
// actions/posts.ts
;('use server')
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await prisma.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
revalidatePath('/blog')
return post
}
9.5 Gotchas and Pitfalls
1. Do not pass non-serializable data to Client Components:
// Error: Date objects are not serializable
<ClientComponent date={new Date()} />
// Fix: Convert to string
<ClientComponent date={new Date().toISOString()} />
2. Cannot use Context in Server Components:
// Error: useContext cannot be used in Server Components
// Fix: Separate Provider into a Client Component
// app/providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
)
}
// app/layout.tsx (Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
3. Environment variable access:
// Environment variables without NEXT_PUBLIC_ prefix are server-only
// Freely accessible in Server Components
const dbUrl = process.env.DATABASE_URL // Server-only
// Client Components require NEXT_PUBLIC_ prefix
const apiUrl = process.env.NEXT_PUBLIC_API_URL // Accessible on client
4. Third-party library compatibility:
Many libraries do not yet support Server Components. The workaround:
// Create a wrapper Client Component
// components/client-only-lib.tsx
'use client'
import { SomeClientLib } from 'some-client-lib'
export function ClientOnlyWrapper(props: any) {
return <SomeClientLib {...props} />
}
// Use in Server Component
import { ClientOnlyWrapper } from '@/components/client-only-lib'
export default function Page() {
return <ClientOnlyWrapper data={serverData} />
}
10. Quiz
Q1. What feature in React 19 replaces useMemo, useCallback, and memo?
The React Compiler (formerly React Forget). It analyzes components at build time and automatically applies memoization. Developers no longer need to manually write memoization hooks, resulting in cleaner code and fewer mistakes.
In Next.js 15, it can be enabled via experimental.reactCompiler: true in next.config.js.
Q2. Why were params, searchParams, cookies(), and headers() all changed to async in Next.js 15?
For PPR (Partial Pre-Rendering) optimization. By deferring request data access until the actual point of use, static parts can be pre-rendered at build time while only dynamic parts (those depending on request data) are processed at runtime. This enables serving a static shell immediately while streaming dynamic content for a single page.
Use the npx @next/codemod@canary next-async-request-api . codemod for migration.
Q3. What pattern allows a Client Component's children to remain as Server Components when used inside the Client Component?
The Composition pattern or children pattern.
A Server Component renders a Client Component while passing server-rendered content as the children prop. The Client Component simply renders children as-is, so the passed Server Components maintain their server-rendered state.
Note: Directly importing a Server Component inside a Client Component will automatically convert it to a Client Component.
Q4. Explain the four caching layers in Next.js 15 and distinguish their location (server/client).
-
Request Memoization (Server): Automatically deduplicates identical fetch requests within the same rendering cycle. This is a React feature.
-
Data Cache (Server): Persistently stores fetch responses on the server. Default is disabled (no-store) in Next.js 15, requiring explicit activation.
-
Full Route Cache (Server): Stores HTML and RSC Payload of static routes generated at build time. Applies to SSG/ISR routes.
-
Router Cache (Client): Caches RSC Payload of visited routes in browser memory. Default is 0 seconds in Next.js 15 (always fetches fresh data from server).
Q5. When should you use Server Actions vs Route Handlers (API Routes)?
Server Actions:
- Form submissions and data mutation (CRUD) operations
- When Progressive Enhancement is needed (works without JS)
- When type-safe server function calls are required
- Natural integration with revalidatePath/revalidateTag
- Internal data mutations (DB updates, file saves, etc.)
Route Handlers:
- External system integrations (webhook receivers, OAuth callbacks, etc.)
- API endpoints called by third-party services
- When custom responses are needed (file downloads, image generation, etc.)
- When exposing a REST API externally
General principle: Use Server Actions for internal data mutations and Route Handlers for external system integrations.
11. References
- React 19 Official Blog — React v19 Release Notes
- Next.js 15 Official Blog — Next.js 15 Release Notes
- Next.js Official Docs — App Router
- Next.js Official Docs — Server Components
- Next.js Official Docs — Server Actions and Mutations
- Next.js Official Docs — Caching
- React Official Docs — use() Hook
- React Official Docs — React Compiler
- Vercel Blog — Partial Pre-Rendering
- Next.js Official Docs — Turbopack
- Auth.js (NextAuth.js v5) Official Docs
- Prisma Official Docs — Next.js Integration
- TanStack Query Official Docs — Next.js Integration
- Next.js Official Docs — Migrating from Pages Router
- Web.dev — Core Web Vitals
- Vercel Blog — How React Server Components Work
- Next.js GitHub — next/after RFC
- Next.js Official Docs — Middleware