Split View: TypeScript & Next.js 실전 가이드 — 타입 안전한 풀스택 개발
TypeScript & Next.js 실전 가이드 — 타입 안전한 풀스택 개발
목차
- TypeScript 타입 시스템 심화
- 유틸리티 타입 완전 정복
- 타입 가드와 타입 좁히기
- Next.js 15 App Router
- Server Components vs Client Components
- Server Actions
- 데이터 페칭 전략
- 미들웨어
- 배포 전략
- 성능 최적화
1. TypeScript 타입 시스템 심화
TypeScript는 단순히 JavaScript에 타입을 추가한 것을 넘어, 강력한 타입 레벨 프로그래밍 언어입니다. 이 장에서는 실무에서 자주 쓰이는 고급 타입 패턴을 다룹니다.
1.1 Union 타입과 Intersection 타입
Union 타입은 여러 타입 중 하나를 나타내며, Intersection 타입은 여러 타입을 모두 만족하는 타입입니다.
// Union 타입: 여러 타입 중 하나
type Status = 'idle' | 'loading' | 'success' | 'error'
type ApiResponse =
| { status: 'success'; data: unknown }
| { status: 'error'; message: string }
// Intersection 타입: 여러 타입을 결합
type Timestamped = { createdAt: Date; updatedAt: Date }
type SoftDeletable = { deletedAt: Date | null }
type BaseEntity = Timestamped & SoftDeletable
interface User extends BaseEntity {
id: string
name: string
email: string
}
Discriminated Union은 공통 필드(판별자)를 이용해 타입을 구분하는 패턴으로, 복잡한 상태 관리에 매우 유용합니다.
// Discriminated Union 패턴
type Action =
| { type: 'SET_USER'; payload: User }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_USER':
// action.payload는 자동으로 User 타입
return { ...state, user: action.payload, error: null }
case 'SET_ERROR':
// action.payload는 자동으로 string 타입
return { ...state, error: action.payload }
case 'RESET':
return initialState
}
}
1.2 제네릭 (Generics)
제네릭은 타입을 매개변수화하여 재사용 가능한 컴포넌트를 만드는 핵심 도구입니다.
// 기본 제네릭 함수
function identity<T>(value: T): T {
return value
}
// 제네릭 제약 조건
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// 제네릭 인터페이스
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>
findAll(): Promise<T[]>
create(data: Omit<T, 'id'>): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}
// 제네릭 클래스
class ApiClient<TResponse> {
constructor(private baseUrl: string) {}
async get(path: string): Promise<TResponse> {
const response = await fetch(`${this.baseUrl}${path}`)
return response.json() as Promise<TResponse>
}
}
1.3 조건부 타입 (Conditional Types)
조건부 타입은 입력 타입에 따라 출력 타입이 결정되는 강력한 패턴입니다.
// 기본 조건부 타입
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// infer 키워드로 타입 추출
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
type Resolved = UnpackPromise<Promise<string>> // string
// 배열 요소 타입 추출
type ElementOf<T> = T extends (infer E)[] ? E : never
type Item = ElementOf<string[]> // string
// 함수 반환 타입 추출
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
1.4 매핑된 타입 (Mapped Types)
기존 타입을 변환하여 새로운 타입을 만듭니다.
// 모든 속성을 읽기 전용으로
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
// 모든 속성을 선택적으로
type MyPartial<T> = {
[P in keyof T]?: T[P]
}
// 특정 키만 선택
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// 실전 예제: Form 상태 타입 자동 생성
type FormFields<T> = {
[K in keyof T]: {
value: T[K]
error: string | null
touched: boolean
}
}
interface LoginData {
email: string
password: string
}
type LoginForm = FormFields<LoginData>
// 결과:
// {
// email: { value: string; error: string | null; touched: boolean }
// password: { value: string; error: string | null; touched: boolean }
// }
1.5 템플릿 리터럴 타입 (Template Literal Types)
문자열 리터럴 타입을 조합하여 새로운 문자열 타입을 생성합니다.
// 기본 템플릿 리터럴 타입
type EventName = `on${Capitalize<string>}`
// 구체적인 조합 생성
type Color = 'red' | 'blue' | 'green'
type Size = 'sm' | 'md' | 'lg'
type ClassName = `${Color}-${Size}`
// 'red-sm' | 'red-md' | 'red-lg' | 'blue-sm' | ...
// CSS 속성 자동 생성
type CSSProperty = 'margin' | 'padding'
type Direction = 'top' | 'right' | 'bottom' | 'left'
type SpacingProp = `${CSSProperty}-${Direction}`
// 'margin-top' | 'margin-right' | ... | 'padding-left'
// API 경로 타입 안전성
type ApiRoutes = `/api/${'users' | 'posts' | 'comments'}`
type ApiRouteWithId = `${ApiRoutes}/${string}`
2. 유틸리티 타입 완전 정복
TypeScript는 자주 쓰이는 타입 변환을 위한 내장 유틸리티 타입을 제공합니다.
2.1 기본 유틸리티 타입
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
createdAt: Date
}
// Partial: 모든 속성을 선택적으로
type UpdateUserDto = Partial<User>
// Required: 모든 속성을 필수로
type StrictUser = Required<User>
// Pick: 특정 속성만 선택
type UserPreview = Pick<User, 'id' | 'name'>
// Omit: 특정 속성 제외
type CreateUserDto = Omit<User, 'id' | 'createdAt'>
// Record: 키-값 쌍의 타입
type UserMap = Record<string, User>
type RolePermissions = Record<User['role'], string[]>
2.2 고급 유틸리티 타입
// ReturnType: 함수 반환 타입 추출
function createUser(name: string, email: string) {
return { id: crypto.randomUUID(), name, email, createdAt: new Date() }
}
type CreatedUser = ReturnType<typeof createUser>
// Parameters: 함수 매개변수 타입 추출
type CreateUserParams = Parameters<typeof createUser>
// [name: string, email: string]
// Awaited: Promise 언래핑
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>
// User
// Extract & Exclude
type Status = 'active' | 'inactive' | 'pending' | 'banned'
type ActiveStatus = Extract<Status, 'active' | 'pending'>
// 'active' | 'pending'
type InactiveStatus = Exclude<Status, 'active' | 'pending'>
// 'inactive' | 'banned'
// NonNullable
type MaybeUser = User | null | undefined
type DefiniteUser = NonNullable<MaybeUser>
// User
2.3 커스텀 유틸리티 타입 만들기
// DeepPartial: 중첩 객체까지 모두 선택적으로
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// DeepReadonly: 중첩 객체까지 모두 읽기 전용
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
// RequiredKeys: 특정 키만 필수로
type RequiredKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
type UserWithRequiredEmail = RequiredKeys<Partial<User>, 'email'>
// Prettify: 타입을 펼쳐서 읽기 쉽게
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
3. 타입 가드와 타입 좁히기
런타임에서 타입을 안전하게 좁히는 방법입니다.
3.1 typeof 가드
function formatValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase()
}
if (typeof value === 'number') {
return value.toFixed(2)
}
return value ? 'Yes' : 'No'
}
3.2 instanceof 가드
class ApiError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message)
}
}
class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string>
) {
super(message)
}
}
function handleError(error: unknown): string {
if (error instanceof ApiError) {
return `API Error ${error.statusCode}: ${error.message}`
}
if (error instanceof ValidationError) {
return `Validation Error: ${Object.values(error.fields).join(', ')}`
}
if (error instanceof Error) {
return error.message
}
return 'Unknown error'
}
3.3 in 연산자 가드
interface Dog {
bark(): void
breed: string
}
interface Cat {
meow(): void
color: string
}
function makeSound(animal: Dog | Cat): void {
if ('bark' in animal) {
animal.bark()
} else {
animal.meow()
}
}
3.4 사용자 정의 타입 가드 (is 키워드)
// 커스텀 타입 가드
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
// 배열 필터링에서 타입 가드 활용
const items: (User | null)[] = [user1, null, user2, null]
const users: User[] = items.filter((item): item is User => item !== null)
3.5 satisfies 연산자
TypeScript 4.9에서 도입된 satisfies는 타입 체크와 타입 추론을 동시에 지원합니다.
type Route = {
path: string
element: React.ReactNode
}
// satisfies를 사용하면 타입 체크를 하면서도 리터럴 타입을 유지
const routes = {
home: { path: '/', element: '<Home />' },
about: { path: '/about', element: '<About />' },
blog: { path: '/blog', element: '<Blog />' },
} satisfies Record<string, Route>
// routes.home.path의 타입은 '/' (리터럴)
// 만약 `as const`를 쓰면 readonly가 되어 수정이 불가
type ColorConfig = Record<string, string | string[]>
const palette = {
primary: '#007bff',
secondary: ['#6c757d', '#adb5bd'],
danger: '#dc3545',
} satisfies ColorConfig
// palette.primary는 string 타입
// palette.secondary는 string[] 타입 (배열 메서드 사용 가능)
4. Next.js 15 App Router
Next.js 15의 App Router는 React Server Components를 기반으로 한 새로운 라우팅 시스템입니다.
4.1 app 디렉토리 구조
App Router는 파일 시스템 기반 라우팅을 사용합니다. 각 폴더는 URL 세그먼트에 대응하며, 특수한 파일 이름이 각각의 역할을 담당합니다.
app/
layout.tsx # 루트 레이아웃 (필수)
page.tsx # 홈페이지 (/)
loading.tsx # 로딩 UI
error.tsx # 에러 UI
not-found.tsx # 404 UI
global-error.tsx # 전역 에러 UI
blog/
page.tsx # /blog
[slug]/
page.tsx # /blog/some-post
loading.tsx # 이 라우트의 로딩
dashboard/
layout.tsx # 대시보드 레이아웃
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
(marketing)/ # 라우트 그룹 (URL에 영향 없음)
about/
page.tsx # /about
contact/
page.tsx # /contact
api/
users/
route.ts # API 라우트
4.2 Layout과 Page
// app/layout.tsx - 루트 레이아웃
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A modern full-stack application',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<header>
<nav>{/* 네비게이션 */}</nav>
</header>
<main>{children}</main>
<footer>{/* 푸터 */}</footer>
</body>
</html>
)
}
// app/blog/[slug]/page.tsx - 동적 라우트
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.summary,
}
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
// 정적 생성할 경로 지정
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
4.3 Loading, Error, Not Found
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
)
}
// app/blog/error.tsx
'use client' // Error 컴포넌트는 반드시 클라이언트 컴포넌트
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>문제가 발생했습니다</h2>
<button onClick={() => reset()}>다시 시도</button>
</div>
)
}
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>페이지를 찾을 수 없습니다</h2>
<p>요청하신 페이지가 존재하지 않습니다.</p>
<Link href="/">홈으로 돌아가기</Link>
</div>
)
}
5. Server Components vs Client Components
Next.js 15에서 가장 중요한 개념 중 하나는 서버 컴포넌트와 클라이언트 컴포넌트의 구분입니다.
5.1 Server Components (기본값)
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 서버에서만 실행되므로 번들 크기에 포함되지 않습니다.
// 서버 컴포넌트 - 기본값
// async 함수로 직접 데이터를 페칭할 수 있음
import { db } from '@/lib/db'
export default async function UserList() {
// 직접 DB에 접근 가능 (서버에서만 실행)
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<span>{user.name}</span>
<span>{user.email}</span>
</li>
))}
</ul>
)
}
5.2 Client Components
상호작용이 필요한 경우 파일 상단에 'use client'를 선언합니다.
'use client'
import { useState, useTransition } from 'react'
interface SearchProps {
onSearch: (query: string) => Promise<void>
}
export default function SearchBar({ onSearch }: SearchProps) {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
startTransition(() => {
onSearch(query)
})
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어를 입력하세요..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? '검색 중...' : '검색'}
</button>
</form>
)
}
5.3 경계 설정 패턴
서버 컴포넌트와 클라이언트 컴포넌트의 조합 패턴입니다.
// 서버 컴포넌트가 클라이언트 컴포넌트를 감싸는 패턴
// app/dashboard/page.tsx (서버 컴포넌트)
import { db } from '@/lib/db'
import DashboardClient from './DashboardClient'
export default async function DashboardPage() {
const data = await db.analytics.getOverview()
return <DashboardClient initialData={data} />
}
// app/dashboard/DashboardClient.tsx (클라이언트 컴포넌트)
'use client'
import { useState } from 'react'
interface Props {
initialData: AnalyticsOverview
}
export default function DashboardClient({ initialData }: Props) {
const [data, setData] = useState(initialData)
const [timeRange, setTimeRange] = useState('7d')
return (
<div>
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
>
<option value="7d">7일</option>
<option value="30d">30일</option>
<option value="90d">90일</option>
</select>
<Chart data={data} />
</div>
)
}
서버 컴포넌트와 클라이언트 컴포넌트를 선택하는 기준은 다음과 같습니다.
서버 컴포넌트를 사용하는 경우:
- 데이터를 페칭할 때
- 백엔드 리소스에 직접 접근할 때
- 민감한 정보(API 키, 토큰)를 사용할 때
- 큰 의존성을 서버에 유지하고 싶을 때
클라이언트 컴포넌트를 사용하는 경우:
- 상호작용(이벤트 리스너)이 필요할 때
- useState, useEffect 등 훅을 사용할 때
- 브라우저 전용 API를 사용할 때
- 커스텀 훅에 상태가 포함될 때
6. Server Actions
Server Actions는 서버에서 실행되는 비동기 함수로, 폼 제출과 데이터 변경을 처리합니다.
6.1 기본 Server Action
// app/actions/user.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { db } from '@/lib/db'
const CreateUserSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
email: z.string().email('유효한 이메일을 입력하세요'),
role: z.enum(['admin', 'user']),
})
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
}
const validatedData = CreateUserSchema.safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
}
}
await db.user.create({ data: validatedData.data })
revalidatePath('/users')
redirect('/users')
}
6.2 폼과 함께 사용하기
// app/users/new/page.tsx
import { createUser } from '@/app/actions/user'
export default function NewUserPage() {
return (
<form action={createUser}>
<div>
<label htmlFor="name">이름</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label htmlFor="email">이메일</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="role">역할</label>
<select id="role" name="role">
<option value="user">사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<button type="submit">사용자 생성</button>
</form>
)
}
6.3 클라이언트 컴포넌트에서 Server Action 사용
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions/user'
export default function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, null)
return (
<form action={formAction}>
<input type="text" name="name" placeholder="이름" />
{state?.errors?.name && (
<p className="text-red-500">{state.errors.name}</p>
)}
<input type="email" name="email" placeholder="이메일" />
{state?.errors?.email && (
<p className="text-red-500">{state.errors.email}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '처리 중...' : '생성'}
</button>
</form>
)
}
6.4 보안 고려사항
Server Actions 사용 시 반드시 지켜야 할 보안 사항입니다.
'use server'
import { auth } from '@/lib/auth'
import { rateLimit } from '@/lib/rate-limit'
export async function deletePost(postId: string) {
// 1. 인증 확인
const session = await auth()
if (!session?.user) {
throw new Error('인증이 필요합니다')
}
// 2. 권한 확인
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('권한이 없습니다')
}
// 3. Rate limiting
const limiter = await rateLimit(session.user.id)
if (!limiter.success) {
throw new Error('요청이 너무 많습니다. 잠시 후 다시 시도하세요.')
}
// 4. 입력 검증
const validatedId = z.string().uuid().parse(postId)
// 5. 실제 작업 수행
await db.post.delete({ where: { id: validatedId } })
revalidatePath('/posts')
}
7. 데이터 페칭 전략
Next.js 15에서의 데이터 페칭은 서버 컴포넌트와 함께 더욱 강력해졌습니다.
7.1 서버 컴포넌트에서 fetch
// 기본 fetch - 기본적으로 캐시됨 (Next.js 14)
// Next.js 15부터는 기본이 no-store
async function getPost(slug: string) {
const res = await fetch(
`https://api.example.com/posts/${slug}`,
{
next: {
revalidate: 3600, // 1시간마다 재검증
tags: ['posts'], // 캐시 태그
},
}
)
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
// 캐시하지 않는 요청
async function getCurrentUser() {
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
return res.json()
}
7.2 unstable_cache (서버 전용 캐시)
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
// DB 쿼리 결과를 캐시
const getCachedPosts = unstable_cache(
async (category: string) => {
return db.post.findMany({
where: { category, published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
},
['posts-by-category'], // 캐시 키
{
revalidate: 600, // 10분마다 재검증
tags: ['posts'], // 수동 재검증을 위한 태그
}
)
// 사용
export default async function BlogPage() {
const posts = await getCachedPosts('technology')
return <PostList posts={posts} />
}
7.3 캐시 무효화
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
// 특정 경로의 캐시 무효화
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true },
})
// 특정 경로 재검증
revalidatePath('/blog')
revalidatePath(`/blog/${postId}`)
// 또는 태그 기반 재검증
revalidateTag('posts')
}
7.4 병렬 데이터 페칭
// 순차적 (느림)
export default async function Dashboard() {
const user = await getUser()
const posts = await getPosts() // user 완료 후 시작
const analytics = await getAnalytics() // posts 완료 후 시작
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// 병렬 (빠름)
export default async function Dashboard() {
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
])
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// Suspense를 활용한 스트리밍
import { Suspense } from 'react'
export default async function Dashboard() {
const user = await getUser() // 필수 데이터
return (
<div>
<UserProfile user={user} />
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection />
</Suspense>
</div>
)
}
8. 미들웨어
미들웨어는 요청이 완료되기 전에 코드를 실행하여 인증, 리다이렉트, 국제화 등을 처리합니다.
8.1 기본 미들웨어
// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 인증 토큰 확인
const token = request.cookies.get('auth-token')?.value
// 보호된 경로 확인
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
}
// 응답 헤더 추가
const response = NextResponse.next()
response.headers.set('x-request-id', crypto.randomUUID())
return response
}
export const config = {
matcher: [
// 정적 파일과 API 라우트 제외
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
8.2 국제화 미들웨어
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['ko', 'en', 'ja']
const defaultLocale = 'ko'
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
const languages = new Negotiator({
headers: negotiatorHeaders,
}).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// 이미 로케일이 있는지 확인
const pathnameHasLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) return
// 적절한 로케일로 리다이렉트
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
8.3 Rate Limiting 미들웨어
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const rateLimit = new Map<string, { count: number; resetTime: number }>()
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const windowMs = 60 * 1000 // 1분
const maxRequests = 60
const current = rateLimit.get(ip)
if (!current || now > current.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
} else if (current.count >= maxRequests) {
return NextResponse.json(
{ error: 'Too Many Requests' },
{ status: 429 }
)
} else {
current.count++
}
}
return NextResponse.next()
}
9. 배포 전략
9.1 Vercel 배포
Vercel은 Next.js의 제작사이며 가장 최적화된 배포 환경을 제공합니다.
# Vercel CLI 설치 및 배포
npm i -g vercel
vercel
# 프로덕션 배포
vercel --prod
# 환경 변수 설정
vercel env add DATABASE_URL production
환경 변수 관리를 위한 설정 파일 예시입니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 환경 변수 검증
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
// 이미지 최적화
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
},
],
},
}
export default nextConfig
9.2 Docker 배포
# Dockerfile
FROM node:20-alpine AS base
# 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn install --frozen-lockfile
# 빌드
FROM base AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN yarn build
# 프로덕션
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
// next.config.ts - standalone 출력 설정
const nextConfig: NextConfig = {
output: 'standalone',
}
9.3 셀프호스팅
# PM2를 사용한 프로세스 관리
npm install -g pm2
# 빌드
npm run build
# PM2로 실행
pm2 start npm --name "nextjs-app" -- start
# 클러스터 모드 (CPU 코어 수만큼)
pm2 start npm --name "nextjs-app" -i max -- start
# 자동 재시작 설정
pm2 startup
pm2 save
Nginx 리버스 프록시 설정:
# /etc/nginx/sites-available/nextjs
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 정적 파일 캐싱
location /_next/static {
proxy_pass http://localhost:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
10. 성능 최적화
10.1 Image 최적화
import Image from 'next/image'
// 기본 이미지 최적화
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // LCP 이미지에 사용
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
}
// 반응형 이미지
export function ResponsiveImage() {
return (
<Image
src="/photo.jpg"
alt="Responsive photo"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
)
}
10.2 Font 최적화
// app/layout.tsx
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', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="ko"
className={`${inter.variable} ${notoSansKR.variable}`}
>
<body>{children}</body>
</html>
)
}
10.3 Bundle 분석
# @next/bundle-analyzer 설치
npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
const nextConfig: NextConfig = {
// 기타 설정
}
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(nextConfig)
# 번들 분석 실행
ANALYZE=true npm run build
10.4 Lazy Loading
import dynamic from 'next/dynamic'
// 컴포넌트 지연 로딩
const DynamicChart = dynamic(() => import('@/components/Chart'), {
loading: () => <p>차트 로딩 중...</p>,
ssr: false, // 클라이언트에서만 렌더링
})
// 조건부 로딩
const DynamicEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false }
)
export default function PostEditor() {
const [showEditor, setShowEditor] = useState(false)
return (
<div>
<button onClick={() => setShowEditor(true)}>
에디터 열기
</button>
{showEditor && <DynamicEditor />}
</div>
)
}
10.5 종합 성능 체크리스트
프로덕션 배포 전에 확인해야 할 성능 최적화 항목입니다.
이미지:
- next/image 컴포넌트 사용
- LCP 이미지에 priority 속성 추가
- 적절한 sizes 속성 설정
- WebP/AVIF 포맷 자동 변환 활용
코드 분할:
- dynamic import로 큰 컴포넌트 지연 로딩
- 라우트별 자동 코드 분할 활용
- 불필요한 의존성 제거
캐싱:
- 적절한 revalidate 값 설정
- 캐시 태그를 활용한 세밀한 무효화
- CDN 캐싱 활용
서버 컴포넌트:
- 가능한 한 서버 컴포넌트 활용
- 클라이언트 컴포넌트 경계 최소화
- Suspense로 스트리밍 렌더링 구현
번들 최적화:
- bundle-analyzer로 번들 크기 분석
- tree-shaking 최적화
- barrel export 패턴 주의 (필요한 것만 import)
마무리
TypeScript와 Next.js는 현대 웹 개발의 표준이 되었습니다. TypeScript의 강력한 타입 시스템은 런타임 에러를 컴파일 타임에 잡아주고, Next.js의 App Router와 Server Components는 최적의 사용자 경험과 개발자 경험을 동시에 제공합니다.
핵심 정리:
- TypeScript의 제네릭, 조건부 타입, 매핑된 타입을 활용하면 타입 안전하면서도 유연한 코드를 작성할 수 있습니다
- Server Components를 기본으로 사용하고, 상호작용이 필요한 부분만 Client Components로 분리하세요
- Server Actions는 폼 처리와 데이터 변경의 새로운 표준입니다 (항상 인증과 입력 검증을 잊지 마세요)
- 데이터 페칭은 병렬화하고, Suspense를 활용해 점진적 렌더링을 구현하세요
- 이미지, 폰트, 번들 최적화로 Core Web Vitals를 개선하세요
이 가이드에서 다룬 내용을 바탕으로 타입 안전하고 성능이 뛰어난 풀스택 애플리케이션을 구축해 보세요.
TypeScript & Next.js Practical Guide — Type-Safe Full-Stack Development
Table of Contents
- TypeScript Type System Deep Dive
- Utility Types Mastery
- Type Guards and Type Narrowing
- Next.js 15 App Router
- Server Components vs Client Components
- Server Actions
- Data Fetching Strategies
- Middleware
- Deployment Strategies
- Performance Optimization
1. TypeScript Type System Deep Dive
TypeScript goes far beyond simply adding types to JavaScript -- it is a powerful type-level programming language in its own right. This chapter covers advanced type patterns commonly used in production.
1.1 Union Types and Intersection Types
Union types represent one of several types, while Intersection types represent a type that satisfies all combined types simultaneously.
// Union type: one of several types
type Status = 'idle' | 'loading' | 'success' | 'error'
type ApiResponse =
| { status: 'success'; data: unknown }
| { status: 'error'; message: string }
// Intersection type: combining multiple types
type Timestamped = { createdAt: Date; updatedAt: Date }
type SoftDeletable = { deletedAt: Date | null }
type BaseEntity = Timestamped & SoftDeletable
interface User extends BaseEntity {
id: string
name: string
email: string
}
Discriminated Unions use a common field (the discriminant) to distinguish between types, making them extremely useful for complex state management.
// Discriminated Union pattern
type Action =
| { type: 'SET_USER'; payload: User }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_USER':
// action.payload is automatically typed as User
return { ...state, user: action.payload, error: null }
case 'SET_ERROR':
// action.payload is automatically typed as string
return { ...state, error: action.payload }
case 'RESET':
return initialState
}
}
1.2 Generics
Generics are the core tool for parameterizing types and creating reusable components.
// Basic generic function
function identity<T>(value: T): T {
return value
}
// Generic constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// Generic interface
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>
findAll(): Promise<T[]>
create(data: Omit<T, 'id'>): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}
// Generic class
class ApiClient<TResponse> {
constructor(private baseUrl: string) {}
async get(path: string): Promise<TResponse> {
const response = await fetch(`${this.baseUrl}${path}`)
return response.json() as Promise<TResponse>
}
}
1.3 Conditional Types
Conditional types are powerful patterns where the output type is determined by the input type.
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Extracting types with the infer keyword
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
type Resolved = UnpackPromise<Promise<string>> // string
// Extracting array element type
type ElementOf<T> = T extends (infer E)[] ? E : never
type Item = ElementOf<string[]> // string
// Extracting function return type
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
1.4 Mapped Types
Mapped types transform existing types to create new ones.
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
// Make all properties optional
type MyPartial<T> = {
[P in keyof T]?: T[P]
}
// Select specific keys only
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// Practical example: auto-generate form state types
type FormFields<T> = {
[K in keyof T]: {
value: T[K]
error: string | null
touched: boolean
}
}
interface LoginData {
email: string
password: string
}
type LoginForm = FormFields<LoginData>
// Result:
// {
// email: { value: string; error: string | null; touched: boolean }
// password: { value: string; error: string | null; touched: boolean }
// }
1.5 Template Literal Types
Template literal types combine string literal types to generate new string types.
// Basic template literal types
type EventName = `on${Capitalize<string>}`
// Generate specific combinations
type Color = 'red' | 'blue' | 'green'
type Size = 'sm' | 'md' | 'lg'
type ClassName = `${Color}-${Size}`
// 'red-sm' | 'red-md' | 'red-lg' | 'blue-sm' | ...
// Auto-generate CSS properties
type CSSProperty = 'margin' | 'padding'
type Direction = 'top' | 'right' | 'bottom' | 'left'
type SpacingProp = `${CSSProperty}-${Direction}`
// 'margin-top' | 'margin-right' | ... | 'padding-left'
// Type-safe API routes
type ApiRoutes = `/api/${'users' | 'posts' | 'comments'}`
type ApiRouteWithId = `${ApiRoutes}/${string}`
2. Utility Types Mastery
TypeScript provides built-in utility types for common type transformations.
2.1 Basic Utility Types
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
createdAt: Date
}
// Partial: make all properties optional
type UpdateUserDto = Partial<User>
// Required: make all properties required
type StrictUser = Required<User>
// Pick: select specific properties
type UserPreview = Pick<User, 'id' | 'name'>
// Omit: exclude specific properties
type CreateUserDto = Omit<User, 'id' | 'createdAt'>
// Record: key-value pair types
type UserMap = Record<string, User>
type RolePermissions = Record<User['role'], string[]>
2.2 Advanced Utility Types
// ReturnType: extract function return type
function createUser(name: string, email: string) {
return { id: crypto.randomUUID(), name, email, createdAt: new Date() }
}
type CreatedUser = ReturnType<typeof createUser>
// Parameters: extract function parameter types
type CreateUserParams = Parameters<typeof createUser>
// [name: string, email: string]
// Awaited: unwrap Promise
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>
// User
// Extract and Exclude
type Status = 'active' | 'inactive' | 'pending' | 'banned'
type ActiveStatus = Extract<Status, 'active' | 'pending'>
// 'active' | 'pending'
type InactiveStatus = Exclude<Status, 'active' | 'pending'>
// 'inactive' | 'banned'
// NonNullable
type MaybeUser = User | null | undefined
type DefiniteUser = NonNullable<MaybeUser>
// User
2.3 Building Custom Utility Types
// DeepPartial: make all nested properties optional
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// DeepReadonly: make all nested properties readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
// RequiredKeys: make only specific keys required
type RequiredKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
type UserWithRequiredEmail = RequiredKeys<Partial<User>, 'email'>
// Prettify: flatten type for readability
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
3. Type Guards and Type Narrowing
Type guards provide ways to safely narrow types at runtime.
3.1 typeof Guard
function formatValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase()
}
if (typeof value === 'number') {
return value.toFixed(2)
}
return value ? 'Yes' : 'No'
}
3.2 instanceof Guard
class ApiError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message)
}
}
class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string>
) {
super(message)
}
}
function handleError(error: unknown): string {
if (error instanceof ApiError) {
return `API Error ${error.statusCode}: ${error.message}`
}
if (error instanceof ValidationError) {
return `Validation Error: ${Object.values(error.fields).join(', ')}`
}
if (error instanceof Error) {
return error.message
}
return 'Unknown error'
}
3.3 The in Operator Guard
interface Dog {
bark(): void
breed: string
}
interface Cat {
meow(): void
color: string
}
function makeSound(animal: Dog | Cat): void {
if ('bark' in animal) {
animal.bark()
} else {
animal.meow()
}
}
3.4 User-Defined Type Guards (is keyword)
// Custom type guard
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
// Using type guards with array filtering
const items: (User | null)[] = [user1, null, user2, null]
const users: User[] = items.filter((item): item is User => item !== null)
3.5 The satisfies Operator
Introduced in TypeScript 4.9, satisfies supports both type checking and type inference simultaneously.
type Route = {
path: string
element: React.ReactNode
}
// satisfies performs type checking while preserving literal types
const routes = {
home: { path: '/', element: '<Home />' },
about: { path: '/about', element: '<About />' },
blog: { path: '/blog', element: '<Blog />' },
} satisfies Record<string, Route>
// routes.home.path has type '/' (literal)
// With `as const`, it would become readonly and unmodifiable
type ColorConfig = Record<string, string | string[]>
const palette = {
primary: '#007bff',
secondary: ['#6c757d', '#adb5bd'],
danger: '#dc3545',
} satisfies ColorConfig
// palette.primary has type string
// palette.secondary has type string[] (array methods available)
4. Next.js 15 App Router
The Next.js 15 App Router is a new routing system built on React Server Components.
4.1 The app Directory Structure
The App Router uses file-system based routing. Each folder corresponds to a URL segment, and special file names serve specific roles.
app/
layout.tsx # Root layout (required)
page.tsx # Home page (/)
loading.tsx # Loading UI
error.tsx # Error UI
not-found.tsx # 404 UI
global-error.tsx # Global error UI
blog/
page.tsx # /blog
[slug]/
page.tsx # /blog/some-post
loading.tsx # Loading for this route
dashboard/
layout.tsx # Dashboard layout
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
(marketing)/ # Route group (no URL impact)
about/
page.tsx # /about
contact/
page.tsx # /contact
api/
users/
route.ts # API route
4.2 Layout and Page
// app/layout.tsx - Root layout
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A modern full-stack application',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer */}</footer>
</body>
</html>
)
}
// app/blog/[slug]/page.tsx - Dynamic route
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.summary,
}
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
// Specify paths for static generation
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
4.3 Loading, Error, Not Found
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
)
}
// app/blog/error.tsx
'use client' // Error components must be Client Components
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Page Not Found</h2>
<p>The page you requested does not exist.</p>
<Link href="/">Go Home</Link>
</div>
)
}
5. Server Components vs Client Components
One of the most important concepts in Next.js 15 is the distinction between Server Components and Client Components.
5.1 Server Components (Default)
In the App Router, all components are Server Components by default. They run only on the server and are not included in the client bundle.
// Server Component - default
// Can fetch data directly as an async function
import { db } from '@/lib/db'
export default async function UserList() {
// Direct DB access (runs only on the server)
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<span>{user.name}</span>
<span>{user.email}</span>
</li>
))}
</ul>
)
}
5.2 Client Components
When interactivity is needed, declare 'use client' at the top of the file.
'use client'
import { useState, useTransition } from 'react'
interface SearchProps {
onSearch: (query: string) => Promise<void>
}
export default function SearchBar({ onSearch }: SearchProps) {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
startTransition(() => {
onSearch(query)
})
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
)
}
5.3 Boundary Patterns
Composition patterns for Server Components and Client Components:
// Server Component wrapping a Client Component
// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db'
import DashboardClient from './DashboardClient'
export default async function DashboardPage() {
const data = await db.analytics.getOverview()
return <DashboardClient initialData={data} />
}
// app/dashboard/DashboardClient.tsx (Client Component)
'use client'
import { useState } from 'react'
interface Props {
initialData: AnalyticsOverview
}
export default function DashboardClient({ initialData }: Props) {
const [data, setData] = useState(initialData)
const [timeRange, setTimeRange] = useState('7d')
return (
<div>
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
>
<option value="7d">7 Days</option>
<option value="30d">30 Days</option>
<option value="90d">90 Days</option>
</select>
<Chart data={data} />
</div>
)
}
Guidelines for choosing between Server and Client Components:
Use Server Components when:
- Fetching data
- Accessing backend resources directly
- Using sensitive information (API keys, tokens)
- Keeping large dependencies on the server
Use Client Components when:
- Interactivity (event listeners) is needed
- Using hooks like useState and useEffect
- Using browser-only APIs
- Custom hooks contain state
6. Server Actions
Server Actions are asynchronous functions that execute on the server, handling form submissions and data mutations.
6.1 Basic Server Action
// app/actions/user.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { db } from '@/lib/db'
const CreateUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
role: z.enum(['admin', 'user']),
})
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
}
const validatedData = CreateUserSchema.safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
}
}
await db.user.create({ data: validatedData.data })
revalidatePath('/users')
redirect('/users')
}
6.2 Using with Forms
// app/users/new/page.tsx
import { createUser } from '@/app/actions/user'
export default function NewUserPage() {
return (
<form action={createUser}>
<div>
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="role">Role</label>
<select id="role" name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit">Create User</button>
</form>
)
}
6.3 Using Server Actions in Client Components
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions/user'
export default function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, null)
return (
<form action={formAction}>
<input type="text" name="name" placeholder="Name" />
{state?.errors?.name && (
<p className="text-red-500">{state.errors.name}</p>
)}
<input type="email" name="email" placeholder="Email" />
{state?.errors?.email && (
<p className="text-red-500">{state.errors.email}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Processing...' : 'Create'}
</button>
</form>
)
}
6.4 Security Considerations
Essential security practices when using Server Actions:
'use server'
import { auth } from '@/lib/auth'
import { rateLimit } from '@/lib/rate-limit'
export async function deletePost(postId: string) {
// 1. Authentication check
const session = await auth()
if (!session?.user) {
throw new Error('Authentication required')
}
// 2. Authorization check
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('Unauthorized')
}
// 3. Rate limiting
const limiter = await rateLimit(session.user.id)
if (!limiter.success) {
throw new Error('Too many requests. Please try again later.')
}
// 4. Input validation
const validatedId = z.string().uuid().parse(postId)
// 5. Perform the actual operation
await db.post.delete({ where: { id: validatedId } })
revalidatePath('/posts')
}
7. Data Fetching Strategies
Data fetching in Next.js 15 has become even more powerful with Server Components.
7.1 fetch in Server Components
// Basic fetch - cached by default (Next.js 14)
// Next.js 15 defaults to no-store
async function getPost(slug: string) {
const res = await fetch(
`https://api.example.com/posts/${slug}`,
{
next: {
revalidate: 3600, // Revalidate every hour
tags: ['posts'], // Cache tags
},
}
)
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
// Non-cached request
async function getCurrentUser() {
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
return res.json()
}
7.2 unstable_cache (Server-Only Cache)
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
// Cache DB query results
const getCachedPosts = unstable_cache(
async (category: string) => {
return db.post.findMany({
where: { category, published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
},
['posts-by-category'], // Cache key
{
revalidate: 600, // Revalidate every 10 minutes
tags: ['posts'], // Tags for manual revalidation
}
)
// Usage
export default async function BlogPage() {
const posts = await getCachedPosts('technology')
return <PostList posts={posts} />
}
7.3 Cache Invalidation
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
// Invalidate cache for a specific path
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true },
})
// Revalidate specific paths
revalidatePath('/blog')
revalidatePath(`/blog/${postId}`)
// Or tag-based revalidation
revalidateTag('posts')
}
7.4 Parallel Data Fetching
// Sequential (slow)
export default async function Dashboard() {
const user = await getUser()
const posts = await getPosts() // Starts after user completes
const analytics = await getAnalytics() // Starts after posts completes
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// Parallel (fast)
export default async function Dashboard() {
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
])
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// Streaming with Suspense
import { Suspense } from 'react'
export default async function Dashboard() {
const user = await getUser() // Essential data
return (
<div>
<UserProfile user={user} />
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection />
</Suspense>
</div>
)
}
8. Middleware
Middleware executes code before a request is completed, handling authentication, redirects, internationalization, and more.
8.1 Basic Middleware
// middleware.ts (project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Check authentication token
const token = request.cookies.get('auth-token')?.value
// Check protected routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
}
// Add response headers
const response = NextResponse.next()
response.headers.set('x-request-id', crypto.randomUUID())
return response
}
export const config = {
matcher: [
// Exclude static files and API routes
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
8.2 Internationalization Middleware
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['en', 'ko', 'ja']
const defaultLocale = 'en'
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
const languages = new Negotiator({
headers: negotiatorHeaders,
}).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Check if locale already exists in the path
const pathnameHasLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) return
// Redirect to appropriate locale
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
8.3 Rate Limiting Middleware
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const rateLimit = new Map<string, { count: number; resetTime: number }>()
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const windowMs = 60 * 1000 // 1 minute
const maxRequests = 60
const current = rateLimit.get(ip)
if (!current || now > current.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
} else if (current.count >= maxRequests) {
return NextResponse.json(
{ error: 'Too Many Requests' },
{ status: 429 }
)
} else {
current.count++
}
}
return NextResponse.next()
}
9. Deployment Strategies
9.1 Vercel Deployment
Vercel, the creators of Next.js, provides the most optimized deployment environment.
# Install Vercel CLI and deploy
npm i -g vercel
vercel
# Production deployment
vercel --prod
# Set environment variables
vercel env add DATABASE_URL production
Example configuration file for environment variable management:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Environment variable validation
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
// Image optimization
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
},
],
},
}
export default nextConfig
9.2 Docker Deployment
# Dockerfile
FROM node:20-alpine AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn install --frozen-lockfile
# Build
FROM base AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN yarn build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
// next.config.ts - standalone output configuration
const nextConfig: NextConfig = {
output: 'standalone',
}
9.3 Self-Hosting
# Process management with PM2
npm install -g pm2
# Build
npm run build
# Run with PM2
pm2 start npm --name "nextjs-app" -- start
# Cluster mode (one per CPU core)
pm2 start npm --name "nextjs-app" -i max -- start
# Auto-restart configuration
pm2 startup
pm2 save
Nginx reverse proxy configuration:
# /etc/nginx/sites-available/nextjs
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Static file caching
location /_next/static {
proxy_pass http://localhost:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
10. Performance Optimization
10.1 Image Optimization
import Image from 'next/image'
// Basic image optimization
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Use for LCP images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
}
// Responsive image
export function ResponsiveImage() {
return (
<Image
src="/photo.jpg"
alt="Responsive photo"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
)
}
10.2 Font Optimization
// app/layout.tsx
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', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="en"
className={`${inter.variable} ${notoSansKR.variable}`}
>
<body>{children}</body>
</html>
)
}
10.3 Bundle Analysis
# Install @next/bundle-analyzer
npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
const nextConfig: NextConfig = {
// other configuration
}
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(nextConfig)
# Run bundle analysis
ANALYZE=true npm run build
10.4 Lazy Loading
import dynamic from 'next/dynamic'
// Lazy load components
const DynamicChart = dynamic(() => import('@/components/Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // Render only on client
})
// Conditional loading
const DynamicEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false }
)
export default function PostEditor() {
const [showEditor, setShowEditor] = useState(false)
return (
<div>
<button onClick={() => setShowEditor(true)}>
Open Editor
</button>
{showEditor && <DynamicEditor />}
</div>
)
}
10.5 Comprehensive Performance Checklist
Performance optimization items to verify before production deployment:
Images:
- Use the next/image component
- Add priority attribute to LCP images
- Set appropriate sizes attributes
- Leverage automatic WebP/AVIF format conversion
Code Splitting:
- Lazy load large components with dynamic import
- Leverage automatic per-route code splitting
- Remove unnecessary dependencies
Caching:
- Set appropriate revalidate values
- Use cache tags for fine-grained invalidation
- Leverage CDN caching
Server Components:
- Use Server Components whenever possible
- Minimize Client Component boundaries
- Implement streaming rendering with Suspense
Bundle Optimization:
- Analyze bundle size with bundle-analyzer
- Optimize tree-shaking
- Watch out for barrel export patterns (import only what you need)
Conclusion
TypeScript and Next.js have become the standard for modern web development. TypeScript's powerful type system catches runtime errors at compile time, while the Next.js App Router and Server Components deliver an optimal user experience and developer experience simultaneously.
Key takeaways:
- Use TypeScript generics, conditional types, and mapped types to write code that is both type-safe and flexible
- Use Server Components by default, and separate only interactive parts as Client Components
- Server Actions are the new standard for form handling and data mutations (always remember authentication and input validation)
- Parallelize data fetching and implement progressive rendering with Suspense
- Improve Core Web Vitals through image, font, and bundle optimization
Build type-safe, high-performance full-stack applications based on the concepts covered in this guide.