Skip to content
Published on

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

Authors

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:

  1. Generate the static shell (HTML) at build time
  2. Mark dynamic parts with Suspense boundaries including fallbacks
  3. 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:

ItemNext.js 14Next.js 15
fetch cacheforce-cache (cached by default)no-store (uncached by default)
GET Route HandlerCachedNot cached
Client Router Cache5 minutes0 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:

  1. Server renders the RSC tree to produce the RSC Payload
  2. Includes references (bundle paths) for Client Components
  3. Client receives the RSC Payload and constructs the DOM tree
  4. 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

FeatureServer ActionsRoute Handlers (API Routes)
Use patternForm submissions, data mutationsExternal APIs, webhooks, third-party integrations
Progressive EnhancementSupported (works without JS)Not supported
Type safetyGuaranteed by function signatureManual type definition required
Invocationform action or direct callfetch/HTTP request
CachingAutomatic revalidation integrationManual cache configuration
SecurityAutomatic CSRF protectionManual implementation required
Use caseInternal data mutationsExternal 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

StrategyAt BuildAt RequestRevalidationUse Case
SSG (Static)Generate HTMLServe from cacheOn rebuildBlog, docs
ISRGenerate HTMLCache + background refreshTime/tag-basedProduct lists, news
SSRRender per requestNonePersonalized pages
PPRGenerate static shellRender dynamic parts onlyMixedProduct 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:

MetricWebpackTurbopackImprovement
Cold Start (dev)8.2s1.9s76.7%
Hot Module Replacement520ms19ms96.3%
Route compilation (first access)1.8s0.97s45.8%
Memory usage (2000+ modules)1.2GB650MB45.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).
  1. Request Memoization (Server): Automatically deduplicates identical fetch requests within the same rendering cycle. This is a React feature.

  2. Data Cache (Server): Persistently stores fetch responses on the server. Default is disabled (no-store) in Next.js 15, requiring explicit activation.

  3. Full Route Cache (Server): Stores HTML and RSC Payload of static routes generated at build time. Applies to SSG/ISR routes.

  4. 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

  1. React 19 Official Blog — React v19 Release Notes
  2. Next.js 15 Official Blog — Next.js 15 Release Notes
  3. Next.js Official Docs — App Router
  4. Next.js Official Docs — Server Components
  5. Next.js Official Docs — Server Actions and Mutations
  6. Next.js Official Docs — Caching
  7. React Official Docs — use() Hook
  8. React Official Docs — React Compiler
  9. Vercel Blog — Partial Pre-Rendering
  10. Next.js Official Docs — Turbopack
  11. Auth.js (NextAuth.js v5) Official Docs
  12. Prisma Official Docs — Next.js Integration
  13. TanStack Query Official Docs — Next.js Integration
  14. Next.js Official Docs — Migrating from Pages Router
  15. Web.dev — Core Web Vitals
  16. Vercel Blog — How React Server Components Work
  17. Next.js GitHub — next/after RFC
  18. Next.js Official Docs — Middleware