Next.js App Router 完全ガイド:パフォーマンスとSEOを最適化する実装方法

はじめに

Next.js 13 で導入された App Router は、React Server Components(RSC)を基盤とした新しいルーティングシステムです。本記事では、App Router を使用した実践的な開発手法と、パフォーマンス・SEO を最大化するベストプラクティスを詳しく解説します。

App Router の基本概念

ディレクトリ構造

app/
├── layout.tsx          # ルートレイアウト
├── page.tsx           # ホームページ
├── loading.tsx        # ローディングUI
├── error.tsx          # エラーハンドリング
├── not-found.tsx      # 404ページ
├── global.css         # グローバルスタイル
├── blog/
│   ├── layout.tsx     # ブログレイアウト
│   ├── page.tsx       # ブログ一覧
│   └── [slug]/
│       ├── page.tsx   # ブログ詳細
│       └── loading.tsx
└── api/
    └── posts/
        └── route.ts   # APIルート

ファイルの役割

ファイル名 役割 説明
layout.tsx レイアウト 子ページで共有されるUI
page.tsx ページ ルートのUIを定義
loading.tsx ローディング 自動的なローディング状態
error.tsx エラー境界 エラーハンドリング
template.tsx テンプレート レイアウトに似ているが、新しいインスタンスを作成

Server Components と Client Components

Server Components の実装

// app/posts/page.tsx
import { Metadata } from 'next'
import { getPosts } from '@/lib/api'
import PostList from './PostList'

export const metadata: Metadata = {
  title: 'ブログ記事一覧',
  description: 'Next.js、React、TypeScriptに関する技術記事',
  openGraph: {
    title: 'ブログ記事一覧',
    description: 'Next.js、React、TypeScriptに関する技術記事',
    type: 'website',
  },
}

// Server Component - データフェッチングを直接実行
export default async function PostsPage() {
  // サーバーサイドでデータを取得
  const posts = await getPosts()
  
  // ビルド時にHTMLが生成される
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">最新の記事</h1>
      {/* Client Component にデータを渡す */}
      <PostList posts={posts} />
    </div>
  )
}

// lib/api.ts
import { cache } from 'react'

// React の cache 関数でリクエストをメモ化
export const getPosts = cache(async () => {
  const res = await fetch('https://api.example.com/posts', {
    next: {
      revalidate: 3600, // 1時間ごとに再検証
    },
  })
  
  if (!res.ok) {
    throw new Error('Failed to fetch posts')
  }
  
  return res.json()
})

Client Components の実装

// app/posts/PostList.tsx
'use client'

import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import type { Post } from '@/types'

interface PostListProps {
  posts: Post[]
}

export default function PostList({ posts }: PostListProps) {
  const [searchTerm, setSearchTerm] = useState('')
  const [isPending, startTransition] = useTransition()
  const router = useRouter()
  
  const filteredPosts = posts.filter(post =>
    post.title.toLowerCase().includes(searchTerm.toLowerCase())
  )
  
  const handlePostClick = (slug: string) => {
    startTransition(() => {
      router.push(`/blog/${slug}`)
    })
  }
  
  return (
    <div>
      <input
        type="search"
        placeholder="記事を検索..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="w-full p-3 mb-6 border rounded-lg"
      />
      
      <div className={`grid gap-6 ${isPending ? 'opacity-50' : ''}`}>
        {filteredPosts.map((post) => (
          <article
            key={post.id}
            onClick={() => handlePostClick(post.slug)}
            className="p-6 bg-white rounded-lg shadow-md cursor-pointer hover:shadow-lg transition-shadow"
          >
            <h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <time className="text-sm text-gray-500">
              {new Date(post.publishedAt).toLocaleDateString('ja-JP')}
            </time>
          </article>
        ))}
      </div>
    </div>
  )
}

高度なルーティングパターン

並列ルーティング(Parallel Routes)

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  notifications: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-12 gap-6">
      <div className="col-span-8">{children}</div>
      <div className="col-span-4 space-y-6">
        <div className="bg-white p-4 rounded-lg shadow">
          {analytics}
        </div>
        <div className="bg-white p-4 rounded-lg shadow">
          {notifications}
        </div>
      </div>
    </div>
  )
}

// app/dashboard/@analytics/page.tsx
export default async function Analytics() {
  const data = await fetchAnalytics()
  
  return (
    <div>
      <h2 className="text-xl font-bold mb-4">アナリティクス</h2>
      <div className="space-y-2">
        <p>訪問者数: {data.visitors}</p>
        <p>ページビュー: {data.pageViews}</p>
        <p>直帰率: {data.bounceRate}%</p>
      </div>
    </div>
  )
}

// app/dashboard/@notifications/page.tsx
export default async function Notifications() {
  const notifications = await fetchNotifications()
  
  return (
    <div>
      <h2 className="text-xl font-bold mb-4">通知</h2>
      <ul className="space-y-2">
        {notifications.map((notification) => (
          <li key={notification.id} className="p-2 bg-gray-50 rounded">
            {notification.message}
          </li>
        ))}
      </ul>
    </div>
  )
}

インターセプトルーティング(Intercepting Routes)

// app/photos/[id]/page.tsx
export default function PhotoPage({ params }: { params: { id: string } }) {
  return (
    <div className="container mx-auto p-8">
      <h1>写真詳細ページ</h1>
      <PhotoDetail id={params.id} />
    </div>
  )
}

// app/photos/@modal/(..)photos/[id]/page.tsx
// (..) は一つ上の階層を示す
export default function PhotoModal({ params }: { params: { id: string } }) {
  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
      <div className="bg-white p-8 rounded-lg max-w-4xl max-h-[90vh] overflow-auto">
        <PhotoDetail id={params.id} />
      </div>
    </div>
  )
}

// app/photos/layout.tsx
export default function PhotosLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}

データフェッチングの最適化

ストリーミングとサスペンス

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart, LatestInvoices, CardsSkeleton } from '@/components/dashboard'

export default function DashboardPage() {
  return (
    <main>
      <h1 className="mb-4 text-xl md:text-2xl">ダッシュボード</h1>
      
      {/* 各コンポーネントを個別にストリーミング */}
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        
        <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  )
}

// components/dashboard/RevenueChart.tsx
async function RevenueChart() {
  const revenue = await fetchRevenue() // 3秒かかる処理
  
  return (
    <div className="w-full md:col-span-4">
      <h2 className="mb-4 text-xl md:text-2xl">月次収益</h2>
      {/* グラフの実装 */}
    </div>
  )
}

// データの並列フェッチング
async function CardWrapper() {
  // Promise.all で並列実行
  const [
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  ] = await Promise.all([
    fetchNumberOfInvoices(),
    fetchNumberOfCustomers(),
    fetchTotalPaidInvoices(),
    fetchTotalPendingInvoices(),
  ])
  
  return (
    <>
      <Card title="回収済み" value={totalPaidInvoices} type="collected" />
      <Card title="保留中" value={totalPendingInvoices} type="pending" />
      <Card title="請求書総数" value={numberOfInvoices} type="invoices" />
      <Card title="顧客総数" value={numberOfCustomers} type="customers" />
    </>
  )
}

Server Actions の実装

// app/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/auth'

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: '顧客を選択してください',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: '0円より大きい金額を入力してください' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'ステータスを選択してください',
  }),
  date: z.string(),
})

const CreateInvoice = FormSchema.omit({ id: true, date: true })

export type State = {
  errors?: {
    customerId?: string[]
    amount?: string[]
    status?: string[]
  }
  message?: string | null
}

export async function createInvoice(prevState: State, formData: FormData) {
  // 認証チェック
  const session = await auth()
  if (!session?.user) {
    return {
      message: '認証が必要です',
    }
  }
  
  // バリデーション
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  })
  
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '入力内容にエラーがあります',
    }
  }
  
  const { customerId, amount, status } = validatedFields.data
  const amountInCents = amount * 100
  const date = new Date().toISOString().split('T')[0]
  
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `
  } catch (error) {
    return {
      message: 'データベースエラー: 請求書の作成に失敗しました',
    }
  }
  
  // キャッシュの再検証
  revalidatePath('/dashboard/invoices')
  
  // リダイレクト
  redirect('/dashboard/invoices')
}

// app/dashboard/invoices/create/page.tsx
import Form from '@/components/invoices/create-form'
import Breadcrumbs from '@/components/invoices/breadcrumbs'
import { fetchCustomers } from '@/lib/data'

export default async function Page() {
  const customers = await fetchCustomers()
  
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: '請求書', href: '/dashboard/invoices' },
          {
            label: '請求書作成',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  )
}

// components/invoices/create-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { createInvoice } from '@/app/actions'

export default function Form({ customers }: { customers: Customer[] }) {
  const initialState = { message: null, errors: {} }
  const [state, dispatch] = useFormState(createInvoice, initialState)
  
  return (
    <form action={dispatch}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        {/* 顧客選択 */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            顧客を選択
          </label>
          <select
            id="customer"
            name="customerId"
            className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm"
            defaultValue=""
            aria-describedby="customer-error"
          >
            <option value="" disabled>
              顧客を選択してください
            </option>
            {customers.map((customer) => (
              <option key={customer.id} value={customer.id}>
                {customer.name}
              </option>
            ))}
          </select>
          {state.errors?.customerId && (
            <div id="customer-error" className="mt-2 text-sm text-red-500">
              {state.errors.customerId.map((error: string) => (
                <p key={error}>{error}</p>
              ))}
            </div>
          )}
        </div>
        
        {/* 金額入力 */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            金額
          </label>
          <input
            id="amount"
            name="amount"
            type="number"
            step="0.01"
            placeholder="金額を入力"
            className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm"
            aria-describedby="amount-error"
          />
          {state.errors?.amount && (
            <div id="amount-error" className="mt-2 text-sm text-red-500">
              {state.errors.amount.map((error: string) => (
                <p key={error}>{error}</p>
              ))}
            </div>
          )}
        </div>
        
        {/* ステータス選択 */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            請求書のステータス
          </legend>
          <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  className="h-4 w-4 cursor-pointer border-gray-300"
                  aria-describedby="status-error"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
                >
                  保留中
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  className="h-4 w-4 cursor-pointer border-gray-300"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
                >
                  支払済み
                </label>
              </div>
            </div>
          </div>
          {state.errors?.status && (
            <div id="status-error" className="mt-2 text-sm text-red-500">
              {state.errors.status.map((error: string) => (
                <p key={error}>{error}</p>
              ))}
            </div>
          )}
        </fieldset>
        
        {state.message && (
          <div className="mt-2 text-sm text-red-500">{state.message}</div>
        )}
      </div>
      
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          キャンセル
        </Link>
        <Button type="submit">請求書を作成</Button>
      </div>
    </form>
  )
}

SEO最適化

動的メタデータ

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost, getAllPosts } from '@/lib/api'

interface PageProps {
  params: { slug: string }
}

// 動的なメタデータ生成
export async function generateMetadata(
  { params }: PageProps
): Promise<Metadata> {
  const post = await getPost(params.slug)
  
  if (!post) {
    return {
      title: '記事が見つかりません',
    }
  }
  
  const publishedTime = new Date(post.publishedAt).toISOString()
  const modifiedTime = new Date(post.updatedAt || post.publishedAt).toISOString()
  
  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime,
      modifiedTime,
      authors: [post.author.name],
      images: [
        {
          url: post.ogImage || '/og-image.jpg',
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.ogImage || '/og-image.jpg'],
    },
    alternates: {
      canonical: `https://example.com/blog/${params.slug}`,
    },
  }
}

// 静的パスの生成
export async function generateStaticParams() {
  const posts = await getAllPosts()
  
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function BlogPost({ params }: PageProps) {
  const post = await getPost(params.slug)
  
  if (!post) {
    notFound()
  }
  
  // 構造化データ
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.ogImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt || post.publishedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
    },
    publisher: {
      '@type': 'Organization',
      name: 'エンハンスド株式会社',
      logo: {
        '@type': 'ImageObject',
        url: 'https://example.com/logo.png',
      },
    },
  }
  
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article className="container mx-auto px-4 py-8 max-w-4xl">
        <header className="mb-8">
          <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
          <div className="flex items-center gap-4 text-gray-600">
            <time dateTime={post.publishedAt}>
              {new Date(post.publishedAt).toLocaleDateString('ja-JP')}
            </time>
            <span>•</span>
            <span>{post.author.name}</span>
          </div>
        </header>
        
        <div 
          className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: post.content }}
        />
      </article>
    </>
  )
}

sitemap.xml と robots.txt

// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/api'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com'
  
  // 静的ページ
  const staticPages = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily' as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: 'weekly' as const,
      priority: 0.9,
    },
  ]
  
  // 動的ページ(ブログ記事)
  const posts = await getAllPosts()
  const blogPages = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt || post.publishedAt),
    changeFrequency: 'monthly' as const,
    priority: 0.7,
  }))
  
  return [...staticPages, ...blogPages]
}

// app/robots.ts
import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  const baseUrl = 'https://example.com'
  
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/', '/dashboard/'],
      },
      {
        userAgent: 'Googlebot',
        allow: '/',
      },
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
    host: baseUrl,
  }
}

パフォーマンス最適化

画像の最適化

// components/OptimizedImage.tsx
import Image from 'next/image'

interface OptimizedImageProps {
  src: string
  alt: string
  width: number
  height: number
  priority?: boolean
}

export default function OptimizedImage({
  src,
  alt,
  width,
  height,
  priority = false,
}: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      priority={priority}
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
      className="rounded-lg"
      placeholder="blur"
      blurDataURL={`data:image/svg+xml;base64,${toBase64(shimmer(width, height))}`}
    />
  )
}

// Shimmer effect for placeholder
const shimmer = (w: number, h: number) => `
  <svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <defs>
      <linearGradient id="g">
        <stop stop-color="#f0f0f0" offset="20%" />
        <stop stop-color="#e0e0e0" offset="50%" />
        <stop stop-color="#f0f0f0" offset="70%" />
      </linearGradient>
    </defs>
    <rect width="${w}" height="${h}" fill="#f0f0f0" />
    <rect id="r" width="${w}" height="${h}" fill="url(#g)" />
    <animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite"  />
  </svg>
`

const toBase64 = (str: string) =>
  typeof window === 'undefined'
    ? Buffer.from(str).toString('base64')
    : window.btoa(str)

フォントの最適化

// 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({
  weight: ['400', '500', '700', '900'],
  subsets: ['latin'],
  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 className="font-sans">
        {children}
      </body>
    </html>
  )
}

// globals.css
@layer base {
  :root {
    --font-inter: 'Inter', sans-serif;
    --font-noto-sans-jp: 'Noto Sans JP', sans-serif;
  }
  
  body {
    font-family: var(--font-noto-sans-jp), var(--font-inter);
  }
  
  /* 英数字のみ Inter を使用 */
  .font-inter {
    font-family: var(--font-inter);
  }
}

バンドルサイズの最適化

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 実験的機能の有効化
  experimental: {
    optimizePackageImports: ['@/components/ui'],
  },
  
  // 画像最適化の設定
  images: {
    formats: ['image/avif', 'image/webp'],
    domains: ['images.example.com'],
  },
  
  // SWC minifier の設定
  swcMinify: true,
  
  // モジュールのエイリアス設定
  webpack: (config) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname, 'src'),
    }
    
    // バンドル分析
    if (process.env.ANALYZE === 'true') {
      const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
      config.plugins.push(
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          reportFilename: './analyze.html',
          openAnalyzer: true,
        })
      )
    }
    
    return config
  },
}

module.exports = nextConfig

// Dynamic imports for code splitting
// components/DynamicChart.tsx
import dynamic from 'next/dynamic'

const Chart = dynamic(() => import('./Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // クライアントサイドのみでレンダリング
})

export default function DynamicChart({ data }: { data: ChartData }) {
  return <Chart data={data} />
}

エラーハンドリングとロギング

グローバルエラーハンドリング

// app/error.tsx
'use client'

import { useEffect } from 'react'
import { captureException } from '@sentry/nextjs'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // エラーをロギングサービスに送信
    captureException(error)
  }, [error])
  
  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <h2 className="mb-4 text-2xl font-bold">エラーが発生しました</h2>
      <p className="mb-8 text-gray-600">
        申し訳ございません。予期しないエラーが発生しました。
      </p>
      <button
        onClick={reset}
        className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
      >
        もう一度試す
      </button>
    </div>
  )
}

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div className="flex min-h-screen items-center justify-center">
          <div className="text-center">
            <h2 className="mb-4 text-2xl font-bold">
              アプリケーションエラー
            </h2>
            <p className="mb-8">
              システムエラーが発生しました。しばらくしてからもう一度お試しください。
            </p>
            <button
              onClick={reset}
              className="rounded bg-blue-500 px-4 py-2 text-white"
            >
              再読み込み
            </button>
          </div>
        </div>
      </body>
    </html>
  )
}

カスタムロギング

// lib/logger.ts
import pino from 'pino'

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
    },
  },
})

// Server Component でのロギング
export function logServerAction(action: string, data?: any) {
  logger.info({
    type: 'server_action',
    action,
    data,
    timestamp: new Date().toISOString(),
  })
}

// API Route でのロギング
export function logApiRequest(
  method: string,
  path: string,
  statusCode: number,
  duration: number
) {
  logger.info({
    type: 'api_request',
    method,
    path,
    statusCode,
    duration,
    timestamp: new Date().toISOString(),
  })
}

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { logApiRequest } from '@/lib/logger'

export function middleware(request: NextRequest) {
  const start = Date.now()
  
  // レスポンスの処理
  const response = NextResponse.next()
  
  // APIリクエストのロギング
  if (request.nextUrl.pathname.startsWith('/api')) {
    const duration = Date.now() - start
    logApiRequest(
      request.method,
      request.nextUrl.pathname,
      response.status,
      duration
    )
  }
  
  return response
}

export const config = {
  matcher: ['/api/:path*'],
}

まとめ

Next.js App Router は、React Server Components の力を最大限に活用し、高速でSEOに優れたWebアプリケーションを構築するための強力なフレームワークです。

重要なポイント:

  • Server Components でデータフェッチングを最適化
  • Client Components は必要最小限に
  • ストリーミングとサスペンスで UX を向上
  • Server Actions で フォーム処理を簡潔に
  • 適切なキャッシング戦略でパフォーマンスを最大化

エンハンスド株式会社では、Next.js App Router を活用した高性能なWebアプリケーション開発を支援しています。お気軽にお問い合わせください。


タグ: #NextJS #AppRouter #React #ServerComponents #Performance #SEO

執筆者: エンハンスド株式会社 フロントエンド開発部

公開日: 2024年12月20日