Skip to content
Published on

TypeScript & Next.js 実践ガイド — 型安全なフルスタック開発

Authors

目次

  1. TypeScript型システム詳解
  2. ユーティリティ型完全攻略
  3. 型ガードと型の絞り込み
  4. Next.js 15 App Router
  5. Server Components vs Client Components
  6. Server Actions
  7. データフェッチング戦略
  8. ミドルウェア
  9. デプロイ戦略
  10. パフォーマンス最適化

1. TypeScript型システム詳解

TypeScriptは単にJavaScriptに型を追加したものを超え、強力な型レベルプログラミング言語です。この章では、実務でよく使われる高度な型パターンを解説します。

1.1 ユニオン型とインターセクション型

ユニオン型は複数の型のいずれかを表し、インターセクション型は複数の型をすべて満たす型を表します。

// ユニオン型: 複数の型のいずれか
type Status = 'idle' | 'loading' | 'success' | 'error'

type ApiResponse =
  | { status: 'success'; data: unknown }
  | { status: 'error'; message: string }

// インターセクション型: 複数の型を結合
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]
}

// 実践例: フォーム状態型の自動生成
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="ja">
      <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. レートリミット
  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 = ['ja', 'en', 'ko']
const defaultLocale = 'ja'

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 レートリミットミドルウェア

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 --from=deps /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 --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出力設定
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_JP } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '500', '700'],
  display: 'swap',
  variable: '--font-noto-sans-jp',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html
      lang="ja"
      className={`${inter.variable} ${notoSansJP.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は現代のWeb開発の標準となりました。TypeScriptの強力な型システムはランタイムエラーをコンパイル時にキャッチし、Next.jsのApp RouterとServer Componentsは最適なユーザーエクスペリエンスとデベロッパーエクスペリエンスを同時に提供します。

重要ポイント:

  • TypeScriptのジェネリクス、条件付き型、マッピング型を活用すれば、型安全で柔軟なコードが書けます
  • Server Componentsをデフォルトで使用し、インタラクションが必要な部分だけClient Componentsとして分離しましょう
  • Server Actionsはフォーム処理とデータ変更の新しい標準です (認証と入力バリデーションを忘れずに)
  • データフェッチングは並列化し、Suspenseを活用して段階的なレンダリングを実装しましょう
  • イメージ、フォント、バンドル最適化でCore Web Vitalsを改善しましょう

このガイドで解説した内容を基に、型安全でパフォーマンスに優れたフルスタックアプリケーションを構築してください。