サイトアイコンmaita tomoya dev io

Next.js

frameworks

Next.js

Next.jsとは何か

Next.jsはReactベースのフルスタックフレームワーク。2016年にVercel(旧ZEIT)が開発・公開した。Reactは本来UIライブラリであり、ルーティングやサーバーサイドレンダリング(SSR)は自前で構築する必要があった。Next.jsはその課題を解決し、Reactアプリケーションを「すぐにプロダクションで使える状態」にするために生まれた。

たとえるなら、Reactが「エンジン」だとすれば、Next.jsは「完成した車」。エンジン単体では走れないが、車になれば道路を走れる。

Next.jsの核心的な特徴

特徴説明たとえ
ハイブリッドレンダリングSSR/SSG/ISR/CSRを1つのアプリ内で使い分け可能料理の注文に応じて焼きたて・作り置き・温め直しを選べるレストラン
ファイルベースルーティングフォルダ構造がそのままURLになる図書館の棚番号がそのまま本の住所になる
フルスタック対応フロントエンドとバックエンドを1つのプロジェクトで管理表の接客と裏の厨房を一人でこなす
開発者体験(DX)高速なHMR、TypeScript対応、ESLint統合道具が全部揃った工房で作業する

なぜNext.jsが生まれたのか

2016年以前のReactの課題

Reactは強力なUIライブラリだったが、プロダクション利用には多くの「自分で選ぶ・設定する」作業が必要だった。

graph TD A[React単体の課題] --> B[ルーティングがない] A --> C[SSRの実装が複雑] A --> D[コード分割が手動] A --> E[SEO対策が困難] A --> F[ビルド設定が複雑] B --> G[react-router等を自分で導入] C --> H[Express + ReactDOMServer.renderToStringを自前実装] D --> I[webpack設定を手動管理] E --> J[SPAはクローラーがコンテンツを取得できない] F --> K[webpack/babel/eslintの設定ファイルが膨大に] G --> L[Next.jsが全てを統合して解決] H --> L I --> L J --> L K --> L style L fill:#0070f3,color:#fff

Vercel(旧ZEIT)の思想

Vercelの創業者Guillermo Rauchは「フロントエンド開発はもっとシンプルであるべき」という信念を持っていた。ZEITはNode.jsのリアルタイムフレームワーク「Socket.io」の開発者でもあるRauchが設立した企業で、デプロイプラットフォーム「Now(現Vercel)」を提供していた。

Next.jsはその延長線上で、「ゼロ設定でReactアプリをSSR対応にする」という目的で誕生した。

Next.jsの進化の歴史

バージョン主な機能追加
1.02016初版リリース。SSR、ファイルベースルーティング
9.02019API Routes、自動最適化
9.32020getStaticProps/getServerSideProps 導入
10.02020画像最適化(next/image)、国際化対応
12.02021Middlewareの導入、Rust製コンパイラ(SWC)
13.02022App Router(ベータ)、React Server Components
14.02023App Router安定版、Server Actions
15.02024Turbopack安定版、部分プリレンダリング

レンダリング戦略

Next.jsの最大の強みは、用途に応じて最適なレンダリング戦略を選べること。

SSR(Server-Side Rendering)

リクエストのたびにサーバーでHTMLを生成する。常に最新のデータを表示したいページに適している。

// App Router: サーバーコンポーネント + 動的レンダリング
async function ProductPage({ params }: { params: { id: string } }) {
  // リクエストごとにデータを取得
  const product = await fetch(`https://api.example.com/products/${params.id}`, {
    cache: 'no-store', // キャッシュしない = SSR
  })
  const data = await product.json()

  return (
    <div>
      <h1>{data.name}</h1>
      <p>価格: {data.price}円</p>
      <p>在庫: {data.stock}個</p>
    </div>
  )
}

SSG(Static Site Generation)

ビルド時にHTMLを生成する。コンテンツが頻繁に変わらないページに最適。

// App Router: デフォルトでSSG(静的レンダリング)
async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
  const data = await post.json()

  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.content }} />
    </article>
  )
}

// ビルド時に生成するパスを指定
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map((post: { slug: string }) => ({ slug: post.slug }))
}

ISR(Incremental Static Regeneration)

SSGの発展形。静的ページを一定間隔で再生成する。「ほぼ静的だが、たまに更新される」コンテンツに最適。

// App Router: revalidateオプションでISRを実現
async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 }, // 60秒ごとに再生成
  })
  const articles = await news.json()

  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  )
}

レンダリング戦略の使い分け

graph TD A[ページの特性を判断] --> B{データは頻繁に変わる?} B -->|Yes| C{リクエストごとに異なる?} B -->|No| D{たまに更新される?} C -->|Yes| E[SSR<br/>例: ダッシュボード、検索結果] C -->|No| F[ISR 短い間隔<br/>例: ニュースフィード] D -->|Yes| G[ISR 長い間隔<br/>例: ブログ、商品ページ] D -->|No| H[SSG<br/>例: ランディングページ、ドキュメント] style E fill:#ff6b6b,color:#fff style F fill:#ffa94d,color:#fff style G fill:#69db7c,color:#fff style H fill:#4dabf7,color:#fff
戦略生成タイミング適したコンテンツTTFB
SSGビルド時ドキュメント、LP最速
ISRビルド時 + 定期再生成ブログ、ECサイト速い
SSRリクエスト時ダッシュボード、検索やや遅い
CSRクライアント側管理画面、SPA初回遅い

App Router

Pages RouterからApp Routerへ

Next.js 13で導入されたApp Routerは、Next.jsのアーキテクチャを根本から刷新した。

観点Pages Router(従来)App Router(新)
ディレクトリpages/app/
データ取得getServerSideProps, getStaticPropsfetch + React Server Components
レイアウト_app.tsx, _document.tsxlayout.tsx(ネスト可能)
ローディング自前実装loading.tsx(組み込み)
エラー処理_error.tsxerror.tsx(セグメント単位)
コンポーネント全てクライアントコンポーネントデフォルトがサーバーコンポーネント

ファイル規約

App Routerでは特別な名前のファイルが特別な役割を持つ。

app/
├── layout.tsx          # ルートレイアウト(必須)
├── page.tsx            # / のページ
├── loading.tsx         # ローディングUI
├── error.tsx           # エラーUI
├── not-found.tsx       # 404ページ
├── blog/
│   ├── layout.tsx      # ブログ用レイアウト
│   ├── page.tsx        # /blog のページ
│   └── [slug]/
│       └── page.tsx    # /blog/:slug のページ
├── api/
│   └── users/
│       └── route.ts    # API Route: /api/users
└── (marketing)/        # ルートグループ(URLに影響しない)
    ├── about/
    │   └── page.tsx    # /about
    └── contact/
        └── page.tsx    # /contact

Server Components と Client Components

App Routerの最大の変更点は、**React Server Components(RSC)**をデフォルトにしたこと。

// サーバーコンポーネント(デフォルト)
// - サーバーでのみ実行される
// - DBに直接アクセスできる
// - バンドルサイズに影響しない
async function UserList() {
  const users = await db.user.findMany() // DBに直接アクセス
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}
'use client' // この宣言でクライアントコンポーネントになる

import { useState } from 'react'

// クライアントコンポーネント
// - ブラウザで実行される
// - useState, useEffect等のフックが使える
// - イベントハンドラが使える
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}

Server Actions

Next.js 14で安定版となったServer Actionsは、フォーム送信やデータ変更をサーバー側で直接処理できる仕組み。

// app/actions.ts
'use server'

export async function createTodo(formData: FormData) {
  const title = formData.get('title') as string
  await db.todo.create({ data: { title } })
  revalidatePath('/todos')
}
// app/todos/page.tsx
import { createTodo } from '../actions'

export default function TodoPage() {
  return (
    <form action={createTodo}>
      <input name="title" placeholder="新しいToDo" />
      <button type="submit">追加</button>
    </form>
  )
}

プロジェクトの始め方

# Next.jsプロジェクトの作成
npx create-next-app@latest my-app --typescript --tailwind --app --src-dir

cd my-app
npm run dev

生成されるフォルダ構造:

my-app/
├── src/
│   └── app/
│       ├── layout.tsx       # ルートレイアウト
│       ├── page.tsx         # ホームページ
│       ├── globals.css      # グローバルスタイル
│       └── favicon.ico
├── public/                  # 静的ファイル
├── next.config.ts           # Next.js設定
├── tailwind.config.ts       # Tailwind CSS設定
├── tsconfig.json            # TypeScript設定
└── package.json

メリットとデメリット

メリット

メリット詳細
ゼロ設定webpack/babel/TypeScript等の設定が不要
SEO最適化SSR/SSGにより検索エンジンがコンテンツを認識できる
パフォーマンス画像最適化、コード分割、プリフェッチが自動
フルスタックAPI RoutesやServer Actionsでバックエンドも書ける
Vercelとの統合デプロイが極めて簡単(git pushだけ)
巨大なコミュニティ情報が豊富で問題解決が容易

デメリット

デメリット詳細
Vercelへの依存一部機能はVercel以外でのデプロイが困難
学習コストApp Router + RSCの概念が複雑
ビルド時間大規模サイトではビルドに時間がかかる
バンドルサイズフレームワーク自体のオーバーヘッドがある
破壊的変更メジャーバージョンアップで大きな変更がある
ブラックボックス内部の動作が複雑で、問題のデバッグが難しい場合がある

代替フレームワークとの比較

観点Next.jsRemixNuxt.jsAstro
ベースReactReactVue.jsフレームワーク非依存
SSR対応対応対応対応
SSG対応非対応対応得意
ISR対応非対応対応非対応
データ取得fetch + RSCloader/actionuseFetch各種対応
学習コスト中〜高
適したユースケース汎用WebアプリVueエコシステムコンテンツサイト

実践: ミニマルなブログアプリ

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <nav>
          <a href="/">ホーム</a>
          <a href="/blog">ブログ</a>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  )
}
// app/blog/page.tsx
type Post = { id: string; title: string; excerpt: string }

export default async function BlogPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }, // 1時間ごとに再生成
  })
  const posts: Post[] = await res.json()

  return (
    <div>
      <h1>ブログ記事一覧</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2><a href={`/blog/${post.id}`}>{post.title}</a></h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  )
}
// app/blog/[id]/page.tsx
type Post = { id: string; title: string; content: string; createdAt: string }

export default async function BlogPostPage({ params }: { params: { id: string } }) {
  const res = await fetch(`https://api.example.com/posts/${params.id}`)
  const post: Post = await res.json()

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.createdAt).toLocaleDateString('ja-JP')}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

export async function generateStaticParams() {
  const res = await fetch('https://api.example.com/posts')
  const posts: { id: string }[] = await res.json()
  return posts.map(post => ({ id: post.id }))
}

参考文献