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