Skip to content

Split View: Next.js 15 + React 19 완전 가이드: Server Components부터 Server Actions까지 2025 실전 정복

✨ Learn with Quiz
|

Next.js 15 + React 19 완전 가이드: Server Components부터 Server Actions까지 2025 실전 정복

들어가며

React 19와 Next.js 15의 조합은 프론트엔드 개발의 패러다임을 근본적으로 바꾸고 있습니다. 서버 컴포넌트가 기본이 되고, 서버 액션으로 API 레이어가 사라지며, Turbopack이 빌드 속도를 혁신하고, PPR이 정적/동적 렌더링의 경계를 허물었습니다.

이 가이드는 Pages Router 시절의 Next.js를 사용하던 개발자, 또는 React 18까지만 경험한 개발자가 최신 스택으로 전환하기 위한 실전 완전 가이드입니다. 개념 설명에 그치지 않고, 실제 코드 패턴과 마이그레이션 전략까지 다룹니다.


1. React 19의 혁신

React 19는 2024년 12월에 안정 버전으로 출시되었으며, React의 철학 자체를 진화시키는 대규모 업데이트입니다.

1.1 React Compiler (자동 메모이제이션)

React Compiler(이전 명칭 React Forget)는 수동으로 useMemo, useCallback, memo를 작성할 필요를 없앱니다. 컴파일러가 빌드 타임에 컴포넌트를 분석해 자동으로 메모이제이션을 적용합니다.

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가 자동으로 메모이제이션 적용
// useMemo, useCallback, 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>
  )
}

Next.js 15에서 React Compiler를 활성화하려면 다음과 같이 설정합니다:

// next.config.js
module.exports = {
  experimental: {
    reactCompiler: true,
  },
}

설치도 필요합니다:

npm install babel-plugin-react-compiler

1.2 use() Hook - Promise와 Context 읽기

React 19의 use() 훅은 기존 훅과 다르게 조건문 안에서도 호출할 수 있습니다. Promise를 직접 읽어 Suspense와 연동하고, Context도 새로운 방식으로 소비합니다.

import { use, Suspense } from 'react'

// Promise를 직접 use()로 읽기
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // Suspense 경계에서 자동 대기

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

// 조건부로 Context 읽기
function ThemeButton({ showIcon }: { showIcon: boolean }) {
  if (showIcon) {
    const theme = use(ThemeContext)
    return <Icon color={theme.primary} />
  }
  return <button>Click me</button>
}

// 사용
function App() {
  const userPromise = fetchUser(userId) // Promise 생성

  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

1.3 Actions: useActionState, useFormStatus, useOptimistic

React 19는 폼과 비동기 작업을 위한 전용 훅 세트를 도입했습니다.

useActionState - 폼 액션의 상태를 관리합니다:

'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="할 일 입력" />
      {state.errors?.title && <p className="error">{state.errors.title}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '추가 중...' : '추가'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

useFormStatus - 부모 폼의 제출 상태를 자식 컴포넌트에서 읽습니다:

'use client'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending, data, method } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : '제출하기'}
    </button>
  )
}

useOptimistic - 낙관적 업데이트를 선언적으로 처리합니다:

'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) // 즉시 UI 업데이트
    await createTodoAction(formData) // 서버에 실제 저장
  }

  return (
    <div>
      <form action={handleAdd}>
        <input name="title" />
        <button type="submit">추가</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  )
}

1.4 Document Metadata와 Stylesheet 지원

React 19는 title, meta, link 태그를 컴포넌트 트리 어디서든 선언할 수 있습니다:

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>
  )
}

stylesheet의 precedence 속성으로 CSS 로드 순서를 제어하고, Suspense와 연동하여 스타일시트가 로드될 때까지 콘텐츠 렌더링을 대기시킬 수 있습니다.

1.5 Ref as Prop (forwardRef 불필요)

React 19에서는 함수 컴포넌트가 ref를 일반 prop으로 받을 수 있습니다:

// Before (React 18) - forwardRef 필요
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})

// After (React 19) - ref를 그냥 prop으로
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}

2. Next.js 15 핵심 변경사항

Next.js 15는 2024년 10월에 릴리스되었으며, React 19를 공식 지원하는 최초의 메이저 프레임워크 버전입니다.

2.1 Turbopack 안정화

Turbopack이 next dev --turbopack으로 안정 버전이 되었습니다. Rust 기반의 증분 빌드 시스템으로:

  • 로컬 서버 시작: 최대 76.7% 빠름
  • Fast Refresh: 최대 96.3% 빠름
  • 초기 라우트 컴파일: 최대 45.8% 빠름 (캐시 없는 경우)
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

프로덕션 빌드(next build)에서 Turbopack 지원은 아직 실험적이며, --turbopack 플래그로 시도할 수 있습니다.

2.2 비동기 Request APIs

Next.js 15에서 가장 큰 **파괴적 변경(Breaking Change)**입니다. 런타임 Request 정보에 접근하는 API들이 모두 비동기가 되었습니다:

// 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) - 모두 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
  // ...
}

이 변경은 서버 렌더링 최적화를 위한 것입니다. Next.js는 이제 요청 데이터를 실제로 사용하는 시점까지 지연(defer)할 수 있어, 정적 부분은 미리 렌더링하고 동적 부분만 요청 시 처리합니다.

자동 마이그레이션 코드모드:

npx @next/codemod@canary next-async-request-api .

2.3 PPR (Partial Pre-Rendering)

PPR은 하나의 페이지에서 정적 부분과 동적 부분을 동시에 처리하는 혁신적 렌더링 전략입니다:

// next.config.js
module.exports = {
  experimental: {
    ppr: 'incremental', // 라우트별로 점진 적용
  },
}
// app/product/[id]/page.tsx
import { Suspense } from 'react'

export const experimental_ppr = true // 이 라우트에 PPR 활성화

// 정적 쉘: 빌드 타임에 사전 렌더링
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id) // 빌드 타임에 가져옴

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <StaticImage src={product.image} />

      {/* 동적 부분: 요청 시 스트리밍 */}
      <Suspense fallback={<PriceSkeleton />}>
        <DynamicPrice productId={id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <DynamicReviews productId={id} />
      </Suspense>
    </div>
  )
}

// 동적 컴포넌트 - cookies/headers 사용으로 자동 dynamic
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} />
}

PPR의 동작 원리:

  1. 빌드 타임에 정적 쉘(HTML)을 생성
  2. 동적 부분은 Suspense 경계로 표시하여 폴백 포함
  3. 요청이 오면 정적 쉘을 즉시 전송하고, 동적 부분은 스트리밍

2.4 next/after API

응답을 보낸 후 비동기 작업을 실행할 수 있는 새 API입니다:

import { after } from 'next/server'
import { log } from '@/lib/logger'

export default async function Page() {
  const data = await fetchData()

  // 응답 후 실행 - 사용자 응답 지연 없음
  after(() => {
    log('page-view', { data: data.id })
  })

  return <Dashboard data={data} />
}

로깅, 분석, 캐시 워밍 등 사용자 응답에 영향을 주지 않아야 하는 작업에 이상적입니다.

2.5 캐싱 기본값 변경

Next.js 15에서 가장 중요한 철학적 변경입니다:

항목Next.js 14Next.js 15
fetch 캐시force-cache (기본 캐시)no-store (기본 미캐시)
GET Route Handler캐시됨캐시 안됨
Client Router Cache5분0초 (페이지 탐색 시 항상 최신)
// Next.js 15 - 명시적 캐시 설정 권장
const data = await fetch('https://api.example.com/posts', {
  cache: 'force-cache', // 명시적으로 캐시 활성화
  next: { revalidate: 3600 }, // 1시간 재검증
})

3. Server Components 심화

3.1 RSC의 동작 원리

React Server Components의 핵심은 RSC Payload(일명 Flight Protocol)입니다. 서버에서 생성된 이 직렬화 형식은 컴포넌트 트리를 클라이언트에 전달합니다.

렌더링 흐름은 다음과 같습니다:

  1. 서버에서 RSC 트리를 렌더링하여 RSC Payload 생성
  2. 클라이언트 컴포넌트를 위한 참조(번들 경로)를 포함
  3. 클라이언트에서 RSC Payload를 받아 DOM 트리 구성
  4. 클라이언트 컴포넌트만 하이드레이션(hydration)
[Server]                    [Client]
   |                           |
   |-- RSC Payload (stream) -->|
   |   - 직렬화된 서버 컴포넌트  |
   |   - 클라이언트 참조        |-- DOM 구성
   |   - Suspense 경계         |-- 클라이언트 하이드레이션
   |                           |

3.2 서버 vs 클라이언트 컴포넌트 경계

서버 컴포넌트에서 가능한 것:

  • DB 직접 접근
  • 파일 시스템 읽기
  • 서버 전용 라이브러리 사용 (fs, crypto, ORM 등)
  • 환경 변수(서버 전용) 접근
  • 번들 크기에 영향 없음

클라이언트 컴포넌트가 필요한 경우:

  • useState, useEffect 등 React 훅
  • 브라우저 API (localStorage, window 등)
  • 이벤트 리스너 (onClick, onChange 등)
  • 클래스 컴포넌트
  • 서드파티 라이브러리 (대부분 클라이언트 전용)
// app/dashboard/page.tsx (Server Component - 기본)
import { db } from '@/lib/db'
import { DashboardChart } from './chart' // Client Component

export default async function DashboardPage() {
  // 서버에서 직접 DB 접근 - API 레이어 불필요
  const metrics = await db.metrics.findMany({
    where: { date: { gte: thirtyDaysAgo() } },
    orderBy: { date: 'asc' },
  })

  // 직렬화 가능한 데이터만 클라이언트 컴포넌트에 전달
  return (
    <div>
      <h1>대시보드</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[] }) {
  // 클라이언트에서만 실행되는 인터랙티브 차트
  return (
    <LineChart width={800} height={400} data={data}>
      <XAxis dataKey="date" />
      <YAxis />
      <Line type="monotone" dataKey="value" stroke="#8884d8" />
    </LineChart>
  )
}

3.3 Composition 패턴: 서버가 클라이언트를 감싸기

서버 컴포넌트가 클라이언트 컴포넌트의 부모가 되는 것이 RSC의 핵심 패턴입니다. 반대로, 클라이언트 컴포넌트 안에서 서버 컴포넌트를 import하면 안 됩니다 (자동으로 클라이언트가 됨). 대신 children 패턴을 사용합니다:

// 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">
      {/* 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달 */}
      <Sidebar>
        <UserInfo /> {/* 이렇게 하면 서버 컴포넌트로 유지됨 */}
      </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)}>토글</button>
      {isOpen && children} {/* 서버에서 렌더링된 children */}
    </aside>
  )
}

3.4 DB 직접 접근과 API 레이어 제거

서버 컴포넌트의 가장 강력한 이점 중 하나는 별도의 API 엔드포인트 없이 데이터베이스에 직접 접근할 수 있다는 것입니다:

// Before (Pages Router + API Routes)
// 1. pages/api/posts.ts - API 엔드포인트
// 2. lib/fetcher.ts - fetch 유틸
// 3. pages/blog.tsx - getServerSideProps + 클라이언트 컴포넌트

// After (App Router + Server Components)
// 단 하나의 파일로 충분
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { cache } from 'react'

// React cache로 같은 요청 내 중복 방지
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>블로그</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 번들 크기 최적화

서버 컴포넌트에서 사용하는 라이브러리는 클라이언트 번들에 포함되지 않습니다:

// 서버 컴포넌트 - 이 import들은 클라이언트 번들에 포함 안됨
import { marked } from 'marked' // 35KB
import hljs from 'highlight.js' // 180KB
import { parse } from 'yaml' // 25KB
import sanitizeHtml from 'sanitize-html' // 60KB

// 총 300KB의 라이브러리가 클라이언트에 전혀 전송되지 않음!
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 실전

Server Actions는 'use server' 지시어로 정의하며, 클라이언트에서 직접 서버 함수를 호출하는 RPC 패턴입니다.

4.1 Form 처리: 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('유효한 이메일을 입력하세요'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다'),
})

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: '이메일 또는 비밀번호가 잘못되었습니다',
      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">이메일</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">비밀번호</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 ? '로그인 중...' : '로그인'}
      </button>
    </form>
  )
}

이 폼은 Progressive Enhancement를 지원합니다. JavaScript가 비활성화된 환경에서도 HTML form 자체가 동작합니다.

4.2 낙관적 업데이트 실전

// app/comments/comment-section.tsx
'use client'

import { useOptimistic, useRef } from 'react'
import { useActionState } 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

    // 즉시 UI 반영 (낙관적)
    addOptimisticComment({
      id: `temp-${Date.now()}`,
      text,
      author: '나',
      createdAt: new Date().toISOString(),
      pending: true,
    })

    formRef.current?.reset()

    // 서버에 실제 저장
    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>전송 중...</span>}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={handleSubmit}>
        <textarea name="text" placeholder="댓글을 입력하세요" required />
        <button type="submit">댓글 작성</button>
      </form>
    </div>
  )
}

4.3 파일 업로드

// 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: '파일을 선택해주세요' }
  }

  // 파일 크기 제한 (5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: '파일 크기는 5MB 이하여야 합니다' }
  }

  // 허용된 타입 확인
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return { error: 'JPEG, PNG, WebP 이미지만 업로드 가능합니다' }
  }

  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와 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,
    },
  })

  // 특정 경로 재검증
  revalidatePath(`/blog/${id}`)

  // 태그 기반 재검증 (더 세밀한 제어)
  revalidateTag('posts')
  revalidateTag(`post-${id}`)
}

// 데이터 페칭 시 태그 지정
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 비교

기능Server ActionsRoute Handlers (API Routes)
사용 패턴폼 제출, 데이터 변경외부 API, 웹훅, 제3자 연동
Progressive Enhancement지원 (JS 없이 동작)미지원
타입 안전성함수 시그니처로 보장수동 타입 정의 필요
호출 방식form action 또는 직접 호출fetch/HTTP 요청
캐싱자동 revalidation 통합수동 캐시 설정
보안자동 CSRF 보호수동 구현 필요
사용처내부 데이터 변경외부 시스템 연동

5. App Router 심화 패턴

5.1 Parallel Routes (@slot)

하나의 레이아웃에서 여러 페이지를 동시에 렌더링합니다:

app/
  dashboard/
    @analytics/
      page.tsx      ← 분석 슬롯
      loading.tsx   ← 분석 로딩 상태
    @team/
      page.tsx      ← 팀 슬롯
      loading.tsx   ← 팀 로딩 상태
    layout.tsx      ← 두 슬롯 조합
    page.tsx        ← 메인 콘텐츠
// 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>
  )
}

각 슬롯은 독립적으로 로딩되며, 하나가 느려도 나머지는 먼저 표시됩니다.

5.2 Intercepting Routes

모달 패턴에서 특히 유용합니다. URL을 유지하면서 현재 레이아웃 내에서 다른 라우트를 보여줍니다:

app/
  feed/
    page.tsx           ← 피드 목록
    @modal/
      (..)photo/[id]/
        page.tsx       ← 모달로 사진 보기
      default.tsx
    layout.tsx
  photo/[id]/
    page.tsx           ← 전체 페이지 사진 보기 (직접 접근/새로고침 시)
// 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>
  )
}

피드에서 사진을 클릭하면 모달로 열리지만, URL은 /photo/123으로 변경됩니다. 이 URL을 직접 방문하거나 새로고침하면 전체 페이지 버전이 렌더링됩니다.

5.3 Route Groups

URL 구조에 영향을 주지 않고 라우트를 논리적으로 그룹화합니다:

app/
  (marketing)/URL에 포함 안됨
    layout.tsx         ← 마케팅 전용 레이아웃
    page.tsx/ 경로
    about/
      page.tsx/about
    pricing/
      page.tsx/pricing
  (dashboard)/URL에 포함 안됨
    layout.tsx         ← 대시보드 전용 레이아웃 (사이드바 등)
    dashboard/
      page.tsx/dashboard
    settings/
      page.tsx/settings

5.4 Loading, Error, Not-Found 바운더리

App Router의 파일 컨벤션은 자동으로 Suspense와 Error Boundary를 설정합니다:

// app/blog/loading.tsx - Suspense fallback 자동 적용
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 자동 적용
'use client' // Error 컴포넌트는 반드시 클라이언트 컴포넌트

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="py-10 text-center">
      <h2>문제가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={reset} className="mt-4 bg-blue-500 px-4 py-2 text-white">
        다시 시도
      </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">게시글을 찾을 수 없습니다</h2>
      <p className="mt-2 text-gray-600">요청하신 게시글이 존재하지 않습니다.</p>
    </div>
  )
}

5.5 generateMetadata로 동적 SEO

// 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 활용

// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 인증 체크
  const token = request.cookies.get('session-token')

  if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // 국제화 - 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))
  }

  // 보안 헤더 추가
  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. 캐싱 전략 완전 정복

Next.js의 캐싱은 4가지 레이어로 구성되며, 각각의 역할을 이해하는 것이 중요합니다.

6.1 4가지 캐싱 레이어

1. Request Memoization (요청 메모이제이션)

같은 렌더링 사이클 내에서 동일한 fetch 요청을 자동 중복 제거합니다:

// 같은 렌더링 내 두 컴포넌트에서 동일 호출 → 실제 요청은 1번만
async function Header() {
  const user = await fetch('/api/user') // 요청 1
  return <h1>{user.name}</h1>
}

async function Sidebar() {
  const user = await fetch('/api/user') // 메모이제이션 - 실제 요청 안됨
  return <nav>{user.role}</nav>
}

2. Data Cache (데이터 캐시)

fetch 응답을 서버에 영구 저장합니다 (Next.js 15에서는 기본 비활성):

// 명시적 캐시 설정
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // 영구 캐시
  next: { revalidate: 3600 }, // 1시간 후 재검증
  next: { tags: ['products'] }, // 태그 기반 재검증
})

3. Full Route Cache (전체 라우트 캐시)

빌드 타임에 생성된 정적 라우트의 HTML과 RSC Payload를 캐시합니다:

// 이 페이지는 빌드 타임에 캐시됨 (정적 라우트)
export default async function AboutPage() {
  return <div>회사 소개</div>
}

// generateStaticParams로 동적 경로도 정적 생성
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

4. Router Cache (라우터 캐시)

클라이언트에서 방문한 라우트의 RSC Payload를 메모리에 캐시합니다. Next.js 15에서는 기본 0초 (항상 최신 데이터):

// next.config.js에서 Router Cache 설정 가능
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30, // 동적 라우트: 30초
      static: 180, // 정적 라우트: 180초
    },
  },
}

6.2 cacheLife와 cacheTag (Next.js 15)

이전의 unstable_cache를 대체하는 새로운 캐싱 API입니다:

// next.config.js
module.exports = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      blog: {
        stale: 300, // 5분간 캐시 사용
        revalidate: 900, // 15분마다 백그라운드 재검증
        expire: 86400, // 24시간 후 만료
      },
      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 렌더링 전략 비교표

전략빌드 시요청 시재검증사용 사례
SSG (Static)HTML 생성캐시에서 제공빌드 시블로그, 문서
ISRHTML 생성캐시 + 백그라운드 갱신시간/태그 기반제품 목록, 뉴스
SSR-매 요청 렌더링없음개인화 페이지
PPR정적 쉘 생성동적 부분만 렌더링혼합제품 상세 (가격+설명)

6.4 실전: E-commerce 제품 페이지 캐싱 전략

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_cache } from 'next/cache'

// 제품 기본 정보: 자주 변하지 않으므로 ISR (1시간)
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'] }
)

// 가격/재고: 자주 변하므로 짧은 캐시 (1분)
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'] }
)

// 리뷰: 사용자별 데이터 포함으로 동적
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>가격 로딩...</div>}>
        <PriceSection productId={id} />
      </Suspense>

      <Suspense fallback={<div>리뷰 로딩...</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}% 할인</span>}
      <p>{pricing?.stock ? `재고: ${pricing.stock}` : '품절'}</p>
    </div>
  )
}

async function ReviewSection({ productId }: { productId: string }) {
  const reviews = await getProductReviews(productId)
  return (
    <div>
      <h2>리뷰 ({reviews.length})</h2>
      {reviews.map((review) => (
        <div key={review.id}>
          <strong>{review.author}</strong>
          <p>{review.content}</p>
        </div>
      ))}
    </div>
  )
}

7. 성능 최적화

7.1 Turbopack vs Webpack 벤치마크

Next.js 15에서의 실제 프로젝트 기준 비교:

측정 항목WebpackTurbopack개선율
Cold Start (dev)8.2초1.9초76.7%
Hot Module Replacement520ms19ms96.3%
라우트 컴파일 (첫 접근)1.8초0.97초45.8%
메모리 사용량 (2000+ 모듈)1.2GB650MB45.8%

7.2 next/image, next/font, next/script

// Image 최적화
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
    />
  )
}
// Font 최적화 - 레이아웃 시프트 방지
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="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
      <body>{children}</body>
    </html>
  )
}
// Script 최적화
import Script from 'next/script'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      {/* afterInteractive: 페이지 하이드레이션 후 로드 (기본) */}
      <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
      {/* lazyOnload: 브라우저 유휴 시 로드 */}
      <Script src="https://chatbot.example.com/widget.js" strategy="lazyOnload" />
      {/* worker: Web Worker에서 실행 (실험적) */}
      <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({
  // 기존 설정
})
ANALYZE=true npm run build

Tree Shaking 최적화 팁:

// Bad - 전체 라이브러리 import
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')

// Good - 필요한 함수만 import
import sortBy from 'lodash/sortBy'
const sorted = sortBy(items, 'name')

// Best - 서버 컴포넌트에서 사용 (번들에 포함 안됨)
// 서버 컴포넌트라면 전체 import 해도 무관
import _ from 'lodash'

7.4 Dynamic Imports와 Lazy Loading

import dynamic from 'next/dynamic'

// 클라이언트 전용 컴포넌트 lazy load
const DynamicChart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // 서버 렌더링 건너뛰기
})

// 조건부 로딩
const DynamicModal = dynamic(() => import('@/components/modal'))

export default function Page() {
  const [showModal, setShowModal] = useState(false)

  return (
    <div>
      <DynamicChart data={data} />
      <button onClick={() => setShowModal(true)}>모달 열기</button>
      {showModal && <DynamicModal onClose={() => setShowModal(false)} />}
    </div>
  )
}

7.5 Core Web Vitals 최적화

LCP (Largest Contentful Paint) 최적화:

// 가장 큰 콘텐츠 요소에 priority 설정
<Image src="/hero.jpg" alt="Hero" priority sizes="100vw" />

// 중요 리소스 프리로드
<link rel="preload" href="/api/critical-data" as="fetch" crossOrigin="anonymous" />

CLS (Cumulative Layout Shift) 최적화:

// 이미지 크기 명시 (width/height)
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />

// 폰트 swap으로 FOIT 방지
const font = Inter({ display: 'swap' })

// 동적 콘텐츠에 최소 높이 설정
<div style={{ minHeight: '200px' }}>
  <Suspense fallback={<Skeleton height={200} />}>
    <DynamicContent />
  </Suspense>
</div>

INP (Interaction to Next Paint) 최적화:

// 무거운 작업은 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) // 즉각 반영 (높은 우선순위)

    startTransition(() => {
      // 결과 필터링은 낮은 우선순위
      setResults(filterLargeDataset(e.target.value))
    })
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <ResultList results={results} />}
    </div>
  )
}

8. 실전 프로젝트: 풀스택 대시보드

8.1 프로젝트 구조

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 인증: 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 DB: 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 상태 관리: 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
  const dashboard = await prisma.dashboard.findFirst({
    where: { userId: session.user.id },
    include: { widgets: { orderBy: { position: 'asc' } } },
  })

  // 초기 데이터를 클라이언트 컴포넌트에 전달
  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()

  // 서버 데이터를 초기값으로, 이후 클라이언트에서 갱신
  const { data: dashboard } = useQuery({
    queryKey: ['dashboard'],
    queryFn: () => fetch('/api/dashboard').then((r) => r.json()),
    initialData,
    staleTime: 60_000, // 1분
  })

  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 배포: Vercel Edge Runtime

// app/api/fast-api/route.ts
export const runtime = 'edge' // Edge Runtime 사용

export async function GET(request: Request) {
  const url = new URL(request.url)
  const query = url.searchParams.get('q')

  // Edge에서 가까운 리전에서 실행 - 낮은 지연시간
  const data = await fetch(`https://api.example.com/search?q=${query}`, {
    next: { revalidate: 60 },
  })

  return Response.json(await data.json())
}
// vercel.json - 리전 설정
{
  "regions": ["icn1", "nrt1"], // 서울, 도쿄
  "functions": {
    "app/api/**": {
      "memory": 1024,
      "maxDuration": 30
    }
  }
}

9. Pages Router에서 App Router 마이그레이션

9.1 단계별 점진적 마이그레이션 전략

App Router와 Pages Router는 동시에 존재할 수 있습니다. 이를 활용한 점진적 마이그레이션 전략:

Phase 1: 레이아웃 마이그레이션
  app/layout.tsx 생성 (루트 레이아웃)
  pages/_app.tsx 로직을 app/layout.tsx로

Phase 2: 정적 페이지부터 이동
  pages/about.tsx → app/about/page.tsx
  pages/pricing.tsx → app/pricing/page.tsx

Phase 3: 동적 라우트 마이그레이션
  pages/blog/[slug].tsx → app/blog/[slug]/page.tsx
  getServerSideProps → async Server Component

Phase 4: API Routes 정리
  내부 호출 → Server Actions로 교체
  외부 연동 → app/api/route.ts (Route Handlers)

Phase 5: 최적화
  캐싱 전략 설정
  Parallel Routes, Intercepting Routes 적용

9.2 getServerSideProps를 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() // 자동으로 not-found.tsx 렌더링
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

9.3 getStaticProps를 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가 기본 (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를 Route Handlers와 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 (외부 연동용): 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)
}

// 내부 데이터 변경은 Server Action으로 대체
// 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 주의사항과 함정

1. 클라이언트 컴포넌트에 직렬화 불가능한 데이터 전달 금지:

// 오류 발생: Date 객체는 직렬화 불가
<ClientComponent date={new Date()} />

// 해결: 문자열로 변환
<ClientComponent date={new Date().toISOString()} />

2. 서버 컴포넌트에서 Context 사용 불가:

// 오류: 서버 컴포넌트에서 useContext 사용 불가
// 해결: 클라이언트 컴포넌트로 Provider 분리

// 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. 환경 변수 접근:

// NEXT_PUBLIC_ 접두사가 없는 환경 변수는 서버에서만 접근 가능
// 서버 컴포넌트에서는 자유롭게 사용
const dbUrl = process.env.DATABASE_URL // 서버 전용

// 클라이언트 컴포넌트에서는 NEXT_PUBLIC_ 접두사 필요
const apiUrl = process.env.NEXT_PUBLIC_API_URL // 클라이언트에서도 접근 가능

4. 서드파티 라이브러리 호환성:

많은 라이브러리가 아직 서버 컴포넌트를 지원하지 않습니다. 해결 방법:

// 래퍼 클라이언트 컴포넌트 생성
// components/client-only-lib.tsx
'use client'

import { SomeClientLib } from 'some-client-lib'

export function ClientOnlyWrapper(props: any) {
  return <SomeClientLib {...props} />
}

// 서버 컴포넌트에서 사용
import { ClientOnlyWrapper } from '@/components/client-only-lib'

export default function Page() {
  return <ClientOnlyWrapper data={serverData} />
}

10. 퀴즈

Q1. React 19에서 useMemo, useCallback, memo를 대체하는 기능은 무엇인가요?

React Compiler(이전 명칭 React Forget)입니다. 빌드 타임에 컴포넌트를 분석하여 자동으로 메모이제이션을 적용합니다. 개발자가 수동으로 메모이제이션 훅을 작성할 필요가 없어지며, 코드가 간결해지고 실수를 줄일 수 있습니다.

Next.js 15에서는 next.config.jsexperimental.reactCompiler: true로 활성화할 수 있습니다.

Q2. Next.js 15에서 params, searchParams, cookies(), headers()가 모두 비동기로 변경된 이유는 무엇인가요?

PPR(Partial Pre-Rendering) 최적화를 위해서입니다. 요청 데이터를 실제 사용 시점까지 지연(defer)할 수 있게 되어, 정적 부분은 빌드 타임에 미리 렌더링하고 동적 부분(요청 데이터 의존)만 런타임에 처리할 수 있습니다. 이를 통해 하나의 페이지에서 정적 쉘을 즉시 제공하고 동적 콘텐츠를 스트리밍하는 것이 가능해졌습니다.

마이그레이션을 위해 npx @next/codemod@canary next-async-request-api . 코드모드를 사용할 수 있습니다.

Q3. 서버 컴포넌트 안에서 클라이언트 컴포넌트를 사용하면서도, 그 클라이언트 컴포넌트의 자식이 서버 컴포넌트로 남도록 하는 패턴은 무엇인가요?

Composition(합성) 패턴 또는 children 패턴입니다.

서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하면서, 서버에서 미리 렌더링된 콘텐츠를 children prop으로 전달합니다. 클라이언트 컴포넌트는 children을 그대로 렌더링만 하므로, 전달된 서버 컴포넌트는 서버에서 렌더링된 상태를 유지합니다.

주의: 클라이언트 컴포넌트에서 서버 컴포넌트를 직접 import하면, 해당 서버 컴포넌트가 자동으로 클라이언트 컴포넌트로 변환됩니다.

Q4. Next.js 15의 4가지 캐싱 레이어를 설명하고, 각각의 위치(서버/클라이언트)를 구분해주세요.
  1. Request Memoization (서버): 같은 렌더링 사이클 내에서 동일한 fetch 요청을 자동 중복 제거합니다. React의 자체 기능입니다.

  2. Data Cache (서버): fetch 응답을 서버에 영구 저장합니다. Next.js 15에서는 기본 비활성(no-store)이며, 명시적으로 활성화해야 합니다.

  3. Full Route Cache (서버): 빌드 타임에 생성된 정적 라우트의 HTML과 RSC Payload를 저장합니다. SSG/ISR 라우트에 적용됩니다.

  4. Router Cache (클라이언트): 방문한 라우트의 RSC Payload를 브라우저 메모리에 캐시합니다. Next.js 15에서는 기본 0초(항상 서버에서 최신 데이터 가져옴)입니다.

Q5. Server Actions과 Route Handlers(API Routes)는 각각 어떤 상황에서 사용하는 것이 적절한가요?

Server Actions:

  • 폼 제출 및 데이터 변경(CRUD) 작업
  • Progressive Enhancement가 필요한 경우 (JS 비활성 환경 대응)
  • 타입 안전한 서버 함수 호출이 필요한 경우
  • revalidatePath/revalidateTag와의 자연스러운 통합
  • 내부 데이터 변경 (DB 업데이트, 파일 저장 등)

Route Handlers:

  • 외부 시스템과의 연동 (웹훅 수신, OAuth 콜백 등)
  • 제3자 서비스가 호출하는 API 엔드포인트
  • 파일 다운로드, 이미지 생성 등 커스텀 응답 필요 시
  • REST API를 외부에 노출해야 하는 경우

일반적인 원칙: 내부 데이터 변경에는 Server Actions, 외부 시스템 연동에는 Route Handlers를 사용합니다.


11. 참고 자료

  1. React 19 공식 블로그 - React v19 릴리스 노트
  2. Next.js 15 공식 블로그 - Next.js 15 릴리스 노트
  3. Next.js 공식 문서 - App Router
  4. Next.js 공식 문서 - Server Components
  5. Next.js 공식 문서 - Server Actions and Mutations
  6. Next.js 공식 문서 - Caching
  7. React 공식 문서 - use() Hook
  8. React 공식 문서 - React Compiler
  9. Vercel 블로그 - Partial Pre-Rendering
  10. Next.js 공식 문서 - Turbopack
  11. Auth.js (NextAuth.js v5) 공식 문서
  12. Prisma 공식 문서 - Next.js 통합
  13. TanStack Query 공식 문서 - Next.js Integration
  14. Next.js 공식 문서 - Migrating from Pages Router
  15. Web.dev - Core Web Vitals
  16. Vercel 블로그 - How React Server Components Work
  17. Next.js GitHub - next/after RFC
  18. Next.js 공식 문서 - Middleware

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

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