Skip to content
Published on

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

Authors

Table of Contents

  1. TypeScript Type System Deep Dive
  2. Utility Types Mastery
  3. Type Guards and Type Narrowing
  4. Next.js 15 App Router
  5. Server Components vs Client Components
  6. Server Actions
  7. Data Fetching Strategies
  8. Middleware
  9. Deployment Strategies
  10. 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 --from=deps /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 --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /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.