- Published on
TypeScript & Next.js Practical Guide — Type-Safe Full-Stack Development
- Authors

- Name
- Youngju Kim
- @fjvbn20031
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.