# Next.js + Tailwind CSS を Cloudflare Pages でデプロイする方法

Next.js と Tailwind CSS で構築したアプリケーションを Cloudflare Pages にデプロイする方法を解説します。Cloudflare Pages は無料プランでも利用でき、グローバル配信と自動スケーリングが可能なホスティングサービスです。SSR(Server-Side Rendering)や Server Actions も利用でき、Next.js アプリケーションをフル機能でデプロイできます。

# Cloudflare Pages とは

Cloudflare Pages は、JAMstack アプリケーション向けのホスティングサービスです。Git リポジトリと連携して自動デプロイが可能で、200 以上のエッジロケーションからグローバル配信されます。Next.js、React、Vue.js などのフレームワークに対応しており、無料プランでも月間 500 回のビルドと 100,000 リクエスト/日の Pages Functions が利用できます。

# Cloudflare Pages へのデプロイ準備

Cloudflare Pages で Next.js アプリケーションをデプロイするには、@cloudflare/next-on-pages パッケージを使用します。このパッケージにより、Next.js の SSR、Server Components、Server Actions などの機能が Cloudflare のエッジランタイムで動作します。

# 1. next.config.js の設定

Cloudflare Pages で SSR を使用する場合、output: 'export' は設定しません。この設定があると静的エクスポートのみとなり、SSR や Server Components が使用できなくなります。

// next.config.js
module.exports = {
  // output: 'export' は設定しない(SSR を使う場合)
};

# 2. @cloudflare/next-on-pages のインストール

Cloudflare Pages で Next.js を動作させるために、@cloudflare/next-on-pages パッケージを開発依存としてインストールします。

npm install -D @cloudflare/next-on-pages

# 3. package.json のビルドスクリプト設定

package.json に Cloudflare Pages 用のビルドスクリプトを追加します。

{
  "scripts": {
    "build": "next build",
    "pages:build": "npx @cloudflare/next-on-pages",
    "pages:deploy": "npm run build && npm run pages:build"
  }
}

# 4. wrangler.toml の作成

プロジェクトルートに wrangler.toml ファイルを作成し、Cloudflare Pages の設定を記述します。

name = "your-app-name"
compatibility_date = "2024-01-01"
pages_build_output_dir = ".vercel/output/static"

# Pages Functions の設定と使い方

Pages Functions は、Cloudflare Pages でサーバーサイドの処理を実行するための機能です。Next.js の API Routes や Server Components で十分な場合が多いですが、Webhook 受信やカスタムミドルウェアなど、特定の用途では Pages Functions が有効です。

# Pages Functions のディレクトリ構造

プロジェクトルートに functions ディレクトリを作成し、その中に API エンドポイントを配置します。

functions/
└── api/
    └── hello.ts

# Pages Functions の実装例

基本的な API エンドポイントの実装例です。

// functions/api/hello.ts
export async function onRequest(context: EventContext) {
  const { request, env } = context;
  return new Response(JSON.stringify({ message: "Hello" }), {
    headers: { "Content-Type": "application/json" },
  });
}

動的ルーティングを使用する場合の例です。

// functions/api/users/[id].ts
export async function onRequestGet(context: EventContext) {
  const { params } = context;
  const user = await fetchUser(params.id);
  return new Response(JSON.stringify(user), {
    headers: { "Content-Type": "application/json" },
  });
}

# Cloudflare Pages での SSR(Server-Side Rendering)

@cloudflare/next-on-pages を使用することで、Cloudflare Pages でも Next.js の SSR 機能が利用できます。Server Components、getServerSideProps、Server Actions、API Routes など、Next.js の主要なサーバーサイド機能が動作します。

# SSR が動作する機能

  • Server Components(App Router)
  • getServerSideProps(Pages Router)
  • Server Actions
  • API Routes(Pages Router)
  • Middleware

# Cloudflare Pages での SSR の制約

Cloudflare Pages はエッジランタイムで動作するため、通常の Node.js サーバーとは異なる制約があります。

  • 実行時間: 30 秒(無料プラン)または 50 秒(有料プラン)
  • ファイルシステム: 読み取り専用(書き込み不可)
  • Node.js API: Web API のみ(fs モジュールの書き込み、child_process などは不可)
  • ネイティブモジュール: 動作しない場合がある

# Server Components の実装例

App Router を使用する場合の Server Components の例です。

// app/users/page.tsx
export default async function UsersPage() {
  const users = await fetch("https://api.example.com/users").then((r) =>
    r.json()
  );
  return (
    <div>
      {users.map((u) => (
        <div key={u.id}>{u.name}</div>
      ))}
    </div>
  );
}

# getServerSideProps の実装例(Pages Router)

Pages Router を使用する場合の getServerSideProps の例です。

// pages/users.tsx
export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch("https://api.example.com/users");
  const users = await res.json();
  return { props: { users } };
};

# Pages Functions が必要なケース

以下のような場合に Pages Functions の使用を検討してください。

  • Webhook 受信: 外部サービスからの Webhook を受信する場合
  • 全リクエストに対するミドルウェア処理: 認証やレート制限など
  • Next.js ルーティング外のエンドポイント: 独立した API エンドポイントが必要な場合

それ以外の場合は、Next.js の標準機能(API Routes、Server Components、Server Actions)で十分です。

# Pages Functions の実践的な使用例

Pages Functions を使用する具体的なシーンと実装例を紹介します。SSR で十分な場合が多いですが、以下のような用途では Pages Functions が有効です。

# Webhook 受信

// functions/api/webhooks/github.ts
import { createHmac } from "crypto";

export async function onRequestPost(context: EventContext) {
  const { request, env } = context;
  const payload = await request.text();
  const signature = request.headers.get("X-Hub-Signature-256");

  // Webhook の署名検証
  const expectedSignature = createHmac("sha256", env.GITHUB_WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");

  if (`sha256=${expectedSignature}` !== signature) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(payload);

  // イベントタイプに応じた処理
  switch (request.headers.get("X-GitHub-Event")) {
    case "push":
      await handlePushEvent(event);
      break;
    case "pull_request":
      await handlePullRequestEvent(event);
      break;
  }

  return new Response("OK", { status: 200 });
}

async function handlePushEvent(event: any) {
  // プッシュイベントの処理
  console.log(`Push to ${event.repository.name}`);
}

async function handlePullRequestEvent(event: any) {
  // プルリクエストイベントの処理
  console.log(`PR ${event.action} in ${event.repository.name}`);
}

# カスタムミドルウェア

// functions/_middleware.ts
export async function onRequest(context: EventContext) {
  const { request, next, env } = context;

  // 認証チェック
  const authHeader = request.headers.get("Authorization");
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return new Response("Unauthorized", { status: 401 });
  }

  const token = authHeader.substring(7);
  const isValid = await validateToken(token, env.JWT_SECRET);

  if (!isValid) {
    return new Response("Invalid token", { status: 401 });
  }

  // レート制限チェック
  const clientIP = request.headers.get("CF-Connecting-IP") || "";
  const rateLimitKey = `rate_limit:${clientIP}`;

  // Cloudflare KV を使用したレート制限(例)
  // const count = await env.RATE_LIMIT_KV.get(rateLimitKey);
  // if (count && parseInt(count) > 100) {
  //   return new Response('Rate limit exceeded', { status: 429 });
  // }

  // カスタムヘッダーの追加
  const response = await next();
  response.headers.set("X-Authenticated-User", "user-id");

  return response;
}

# Server-Sent Events (SSE)

// functions/api/events.ts
export async function onRequestGet(context: EventContext) {
  const { request, env } = context;

  // Server-Sent Events のストリーム
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      // 定期的にイベントを送信
      const interval = setInterval(() => {
        const data = {
          timestamp: new Date().toISOString(),
          message: "Hello from SSE",
        };

        controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
      }, 1000);

      // クライアントが切断したらクリーンアップ
      request.signal.addEventListener("abort", () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

# ファイルアップロード前処理

// functions/api/upload.ts
export async function onRequestPost(context: EventContext) {
  const { request, env } = context;
  const formData = await request.formData();
  const file = formData.get("file") as File;

  if (!file) {
    return new Response("No file provided", { status: 400 });
  }

  // ファイルサイズチェック
  if (file.size > 10 * 1024 * 1024) {
    // 10MB
    return new Response("File too large", { status: 400 });
  }

  // ファイルタイプチェック
  const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
  if (!allowedTypes.includes(file.type)) {
    return new Response("Invalid file type", { status: 400 });
  }

  // Cloudflare R2 や外部ストレージにアップロード
  const fileBuffer = await file.arrayBuffer();
  const uploadUrl = await uploadToStorage(fileBuffer, file.name, env);

  return new Response(JSON.stringify({ url: uploadUrl }), {
    headers: { "Content-Type": "application/json" },
  });
}

async function uploadToStorage(
  buffer: ArrayBuffer,
  filename: string,
  env: any
): Promise<string> {
  // Cloudflare R2 へのアップロード例
  // または外部ストレージサービスへのアップロード
  return "https://storage.example.com/files/" + filename;
}

# ヘルスチェック

// functions/api/health.ts
export async function onRequestGet(context: EventContext) {
  const { env } = context;

  const health = {
    status: "ok",
    timestamp: new Date().toISOString(),
    version: env.APP_VERSION || "unknown",
    region: context.request.cf?.colo || "unknown",
  };

  // データベース接続チェック(オプション)
  try {
    // const dbHealth = await checkDatabase(env);
    // health.database = dbHealth;
  } catch (error) {
    health.status = "degraded";
    health.error = "Database check failed";
  }

  return new Response(JSON.stringify(health), {
    headers: { "Content-Type": "application/json" },
  });
}

# 動的リダイレクト

// functions/_middleware.ts
export async function onRequest(context: EventContext) {
  const { request, next } = context;
  const url = new URL(request.url);

  // 特定のパスをリダイレクト
  if (url.pathname.startsWith("/old-path")) {
    const newPath = url.pathname.replace("/old-path", "/new-path");
    return Response.redirect(new URL(newPath, url.origin), 301);
  }

  // A/B テスト用のリライト
  if (url.pathname === "/landing") {
    const variant = Math.random() > 0.5 ? "a" : "b";
    url.pathname = `/landing/${variant}`;
    return Response.redirect(url, 302);
  }

  return next();
}

# Server Actions

// app/actions.ts
"use server";

export async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // データベースに保存などの処理
  const user = await saveUser({ name, email });

  return { success: true, user };
}
// app/components/UserForm.tsx
import { createUser } from "@/app/actions";

export default function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <button type="submit">作成</button>
    </form>
  );
}

# 制約

  • 実行時間: 30 秒(無料)または 50 秒(有料)
  • ファイルシステム: 読み取り専用
  • データベース接続: 接続プールが制限される(D1 や外部 API を推奨)
  • ネイティブモジュール: 動作しない場合がある

# 実装例

// app/actions/user.ts
"use server";

import { revalidatePath } from "next/cache";

export async function updateUser(userId: string, formData: FormData) {
  const name = formData.get("name") as string;

  // Cloudflare D1 や外部 API を使用
  const response = await fetch(`https://api.example.com/users/${userId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name }),
  });

  if (!response.ok) {
    throw new Error("ユーザー更新に失敗しました");
  }

  revalidatePath("/users");
  return { success: true };
}
// app/users/[id]/edit/page.tsx
import { updateUser } from "@/app/actions/user";

export default function EditUserPage({ params }: { params: { id: string } }) {
  async function handleSubmit(formData: FormData) {
    "use server";
    await updateUser(params.id, formData);
  }

  return (
    <form action={handleSubmit}>
      <input name="name" type="text" required />
      <button type="submit">更新</button>
    </form>
  );
}
  • Server Actions: フォーム処理、データ変更
  • Pages Functions: Webhook 受信、外部 API

# Cloudflare Pages へのデプロイ方法

Cloudflare Pages へのデプロイ方法は 3 つあります。Cloudflare Dashboard からのデプロイ、Wrangler CLI を使用したデプロイ、GitHub Actions を使用した自動デプロイです。

# Cloudflare Dashboard

  1. Pages > Create a project
  2. Git リポジトリを接続
  3. ビルド設定:
    • Build command: npm run pages:build
    • Build output directory: .vercel/output/static

# Wrangler CLI

# Wrangler CLI のインストール
npm install -g wrangler

# ログイン
wrangler login

# ビルドとデプロイ
npm run pages:deploy

# または直接デプロイ
wrangler pages deploy .vercel/output/static --project-name=your-app-name

# GitHub Actions

.github/workflows/deploy.yml:

name: Deploy to Cloudflare Pages

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run pages:build

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: your-app-name
          directory: .vercel/output/static

# 環境変数の設定方法

Cloudflare Pages では、ビルド時とランタイム時の両方で環境変数を設定できます。Cloudflare Dashboard から設定する方法と、wrangler.toml ファイルで設定する方法があります。

# Dashboard

Settings > Environment variables

# wrangler.toml

# wrangler.toml
name = "your-app-name"
compatibility_date = "2024-01-01"
pages_build_output_dir = ".vercel/output/static"

[env.production.vars]
API_URL = "https://api.example.com"
DATABASE_URL = "your-database-url"

# Cloudflare Pages の料金プランと制限

Cloudflare Pages には無料プランと有料プランがあります。無料プランでも月間 500 回のビルドと 100,000 リクエスト/日の Pages Functions が利用でき、小規模なプロジェクトには十分です。

# 無料プラン(Free)

項目 制限
ビルド回数 月間 500 回まで
ビルド時間 1 ビルドあたり最大 20 分
同時ビルド数 1 つまで
プロジェクト数 無制限
サイト数 無制限
帯域幅 無制限
リクエスト数 無制限
ファイル数 最大 20,000 ファイル
ファイルサイズ 1 ファイルあたり最大 25MB
ドメイン割り当て プロジェクトごとに最大 10 ドメイン
Pages Functions リクエスト 100,000 リクエスト/日まで
Pages Functions 実行時間 30 秒まで
プレビュー環境 あり(プルリクエストごと)
カスタムドメイン あり
SSL/TLS 自動(無料)
DDoS 対策 自動(無料)

# 有料プラン(Bundled)

項目 制限
価格 $20/月(Workers と Pages のバンドル)
ビルド回数 月間 5,000 回まで
ビルド時間 1 ビルドあたり最大 20 分
同時ビルド数 5 つまで
プロジェクト数 無制限
サイト数 無制限
帯域幅 無制限
リクエスト数 無制限
ファイル数 最大 20,000 ファイル
ファイルサイズ 1 ファイルあたり最大 25MB
ドメイン割り当て プロジェクトごとに最大 10 ドメイン
Pages Functions リクエスト 無制限
Pages Functions 実行時間 50 秒まで
プレビュー環境 あり(プルリクエストごと)
カスタムドメイン あり
SSL/TLS 自動(無料)
DDoS 対策 自動(無料)
Analytics 詳細な Analytics が利用可能
ログ リアルタイムログが利用可能

# 追加の制限事項

# ビルド環境

  • Node.js バージョン: 18.x または 20.x(デフォルトは 18.x)
  • ビルドコマンド: カスタマイズ可能
  • 環境変数: ビルド時とランタイム時の両方で設定可能

# Pages Functions の制限

  • メモリ: 128MB(無料)、256MB(有料)
  • CPU 時間: 実行時間制限内で利用可能
  • 同時実行数: 制限あり(プランによる)

# ファイルサイズの制限

  • 静的ファイル: 1 ファイルあたり最大 25MB
  • ビルド成果物: 合計で最大 25MB(推奨)
  • 大きなファイル: Cloudflare R2 などの外部ストレージを推奨

# Cloudflare Pages と VPS の比較

Cloudflare Pages と従来の VPS(Virtual Private Server)を比較し、それぞれのメリット・デメリットを解説します。プロジェクトの要件に応じて適切な選択ができるよう、詳細な比較を行います。

# 比較表

項目 Cloudflare Pages + Pages Functions VPS/専用サーバー
初期コスト 無料プランあり(制限あり) 月額数千円〜数万円
スケーラビリティ 自動スケール、ほぼ無制限 手動でスケール、リソース制限あり
グローバル配信 自動(200+ のエッジロケーション) CDN を別途設定が必要
セットアップ時間 数分〜数十分 数時間〜数日
サーバー管理 不要(マネージド) 必要(OS、セキュリティ、更新など)
カスタマイズ性 制限あり(Cloudflare の制約内) 高い(フルコントロール)
実行時間制限 Pages Functions: 30 秒(無料)、50 秒(有料) 制限なし
データベース 外部サービス必須 サーバー内に構築可能
ファイルシステム 読み取り専用 読み書き可能
バックエンド処理 Edge Functions のみ 任意のバックエンドフレームワーク
SSR/Server Components 対応(エッジランタイム制約あり) 完全対応
Server Actions 対応(実行時間制限あり) 完全対応
SSL/TLS 自動(無料) Let's Encrypt などで設定必要
DDoS 対策 自動(無料) 別途対策が必要
ログ管理 Cloudflare Dashboard で確認 サーバーログを自分で管理

# メリット

  • 無料プランあり(月 500 ビルド、100,000 リクエスト/日)
  • グローバル配信(200+ エッジロケーション)
  • サーバー管理不要
  • 自動スケーリング
  • DDoS 対策、SSL/TLS 自動

# デメリット

  • 実行時間制限(30 秒/50 秒)
  • Web API のみ(Node.js 全機能は不可)
  • ファイルシステム読み取り専用
  • データベース接続プールが制限される

# VPS が適しているケース

  • 長時間実行処理(バッチ、動画変換など)
  • カスタムミドルウェアが必要
  • 大容量ファイル処理
  • OS レベルでの完全な制御が必要

# よくある問題とトラブルシューティング

Cloudflare Pages で Next.js アプリケーションをデプロイする際によくある問題とその解決方法をまとめます。

# SSR が動作しない

  • output: 'export' を削除
  • pages:build スクリプトを確認
  • 'use client' ディレクティブがないことを確認

# Server Actions が動作しない

  • 'use server' ディレクティブを確認
  • データベース接続は HTTP API 経由を推奨
  • ファイル書き込みは不可(読み取り専用)

# 実行時間制限エラー

  • 処理を分割
  • 重い処理は外部 API に委譲

# パフォーマンス最適化の方法

Cloudflare Pages で Next.js アプリケーションのパフォーマンスを最適化する方法を解説します。画像最適化やキャッシュ設定により、Core Web Vitals のスコアを向上させることができます。

# 画像最適化

// next.config.js
module.exports = {
  images: {
    loader: "custom",
    loaderFile: "./lib/cloudflare-image-loader.js",
  },
};

# キャッシュ設定

_headers:

/*
  Cache-Control: public, max-age=31536000, immutable

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable

# まとめ

この記事では、Next.js と Tailwind CSS で構築したアプリケーションを Cloudflare Pages にデプロイする方法を解説しました。

# 主なポイント

  1. セットアップ手順

    • @cloudflare/next-on-pages パッケージをインストール
    • wrangler.toml ファイルを作成して設定
    • ビルドスクリプトを package.json に追加
  2. SSR の利用

    • output: 'export' は設定しない(SSR を使用する場合)
    • Server Components、Server Actions が使用可能
    • エッジランタイムの制約に注意(実行時間 30〜50 秒、ファイルシステム読み取り専用)
  3. Pages Functions の使い分け

    • 基本的には Next.js の標準機能(API Routes、Server Components、Server Actions)で十分
    • Webhook 受信やカスタムミドルウェアが必要な場合のみ Pages Functions を使用
  4. デプロイ方法

    • Cloudflare Dashboard、Wrangler CLI、GitHub Actions から選択可能
    • 自動デプロイを設定することで、Git プッシュ時に自動的にデプロイされる
  5. Cloudflare Pages のメリット

    • 無料プランあり(月間 500 回のビルド、100,000 リクエスト/日の Pages Functions)
    • グローバル配信(200 以上のエッジロケーション)
    • サーバー管理不要(マネージドサービス)
    • 自動スケーリング
  6. Cloudflare Pages のデメリット

    • 実行時間制限(30 秒/50 秒)
    • Web API のみ(Node.js の全機能は使用不可)
    • ファイルシステム読み取り専用

# 推奨される用途

小規模から中規模のアプリケーションには Cloudflare Pages が適しています。無料プランでも十分な機能が利用でき、グローバル配信により高速なアクセスが可能です。長時間実行が必要な処理や、カスタムミドルウェアが必要な場合は、VPS を検討してください。

# 参考

同じタグを持つ記事をピックアップしました。