Next.js Cloudflare Turso 本番

約31分で読めます by ぽんたぬき
Next.js Cloudflare Turso 本番

Next.js × Cloudflare × Turso で作る本番環境:エッジで動く高速Webアプリの完全構築ガイド

はじめに:なぜこのスタックなのか

Webアプリケーションの開発において、「高速」「安価」「スケーラブル」を同時に実現するスタック選定は、現代の開発者にとって最重要課題のひとつです。従来のVPSやコンテナベースの構成では、グローバルな低レイテンシを達成するためにマルチリージョン展開が必要となり、インフラコストと運用の複雑さが増大する傾向がありました。

この課題を根本から解決するのが、Next.js × Cloudflare × Turso の組み合わせです。

  • Next.js:React フレームワークのデファクトスタンダード。App Router と Server Components による柔軟なレンダリング戦略
  • Cloudflare Pages / Workers:世界 300 以上の PoP(Points of Presence)でエッジ実行。コールドスタートほぼゼロの V8 Isolate ベースのランタイム
  • Turso:libSQL(SQLite フォーク)をベースにしたエッジネイティブな分散データベース。エッジに近いレプリカを自動配置し、読み取りレイテンシを劇的に削減

本記事では、この 3 つの技術を組み合わせた本番環境の構築手順を、実際の開発現場で培ったベストプラクティスをもとに段階的に解説します。コマンドのコピペで動かすだけでなく、なぜその設定が必要なのかという背景知識も丁寧に説明するので、トラブルシューティングや応用にも役立てていただけます。


対象読者と前提知識

本記事は以下の方を対象としています。

  • Next.js でアプリを開発したことがある
  • Cloudflare のアカウントを持っている、または取得予定
  • SQL の基本的な操作(CREATE TABLE、SELECT、INSERT など)を理解している
  • Node.js と npm / pnpm の基本的な使い方を知っている

本番環境へのデプロイ経験がなくても、手順に沿って進めることで最終的な目標を達成できるよう構成しています。


技術スタックの詳細と選定理由

Next.js と Cloudflare の相性

Next.js は本来 Node.js ランタイムを前提として設計されていますが、@cloudflare/next-on-pages パッケージと Cloudflare の Workers ランタイム(Edge Runtime) を組み合わせることで、Cloudflare Pages 上で動作させることができます。

重要なポイントとして、Cloudflare Workers は Node.js そのものではなく Web Standards API を実装した独自ランタイムです。そのため、fs モジュールや一部の Node.js 組み込みモジュールは使用できません。Next.js の各 Route Segment で export const runtime = 'edge' を宣言することで、このランタイム上での動作が保証されます。

Turso の仕組みと強み

Turso は SQLite をベースにした libSQL を使用しており、以下の特徴があります。

  • エッジレプリカ:書き込みはプライマリ DB(例:東京リージョン)、読み取りはユーザーに最も近いレプリカから行うため、読み取りレイテンシが大幅に低下する
  • HTTP API:libSQL の HTTP ドライバーは、TCP が制限された環境(Cloudflare Workers など)でも動作する
  • 無料枠が充実:月 500 DB、月 9GB のストレージ、月 10 億行の読み取りが無料(2024 年時点)

ベストプラクティスとして、Turso のプライマリリージョンは ユーザーの書き込みが最も集中する地域、またはバックエンド処理が多い場合はコンピュートリソースに近いリージョンを選択することを推奨します。


事前準備:必要なアカウントとツール

必要なアカウント

  1. Cloudflare アカウント(無料プランで開始可能)
  2. Turso アカウント(GitHub / Google でサインアップ可能)
  3. GitHub / GitLab アカウント(Cloudflare Pages との CI/CD 連携に使用)

ローカル環境のセットアップ

# Node.js 18.x 以上を推奨(Cloudflare Pages の要件)
node -v  # v18.x 以上であることを確認

# Turso CLI のインストール
curl -sSfL https://get.tur.so/install.sh | bash

# Wrangler(Cloudflare の CLI ツール)のインストール
npm install -g wrangler

# Turso CLI にログイン
turso auth login

Step 1:Next.js プロジェクトの作成

プロジェクトの初期化

npx create-next-app@latest my-edge-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd my-edge-app

Cloudflare 連携パッケージのインストール

npm install --save-dev @cloudflare/next-on-pages
npm install @libsql/client

@cloudflare/next-on-pages は、Next.js のビルド出力を Cloudflare Pages が解釈できる形式に変換するアダプターです。実際の開発現場では、このパッケージのバージョンと Next.js のバージョンの組み合わせに注意が必要です。公式の Compatibility Matrix を確認する習慣をつけておきましょう。

next.config.ts の設定

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Cloudflare Pages 向けの設定
  // 静的エクスポートは使用しない(動的ルートのため)
  experimental: {
    // Server Actions を有効化(必要な場合)
    serverActions: {
      allowedOrigins: ["localhost:3000"],
    },
  },
};

export default nextConfig;

Edge Runtime の宣言

Cloudflare Workers で動作させるすべての Route Segment に Edge Runtime を宣言します。

// src/app/layout.tsx
export const runtime = "edge";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

Step 2:Turso データベースの作成とスキーマ設定

データベースの作成

# プライマリリージョンを指定してDBを作成(nrt = 東京)
turso db create my-edge-app-db --location nrt

# 接続情報を確認
turso db show my-edge-app-db
# → URL: libsql://my-edge-app-db-<your-org>.turso.io

# 認証トークンの発行
turso db tokens create my-edge-app-db
# → eyJhbGciOiJFZERTQSJ9... (このトークンは安全に保管してください)

エッジレプリカの追加(本番推奨)

本番環境では、ユーザーが集中するリージョンにレプリカを追加することで、読み取りパフォーマンスを最大化できます。

# 北米リージョンにレプリカを追加
turso db replicas add my-edge-app-db iad  # ワシントン DC
turso db replicas add my-edge-app-db sfo  # サンフランシスコ

# ヨーロッパリージョンにもレプリカを追加
turso db replicas add my-edge-app-db ams  # アムステルダム

# レプリカの状態確認
turso db show my-edge-app-db --replicas

スキーマの設計と適用

実際の開発現場では、Drizzle ORM や Prisma を用いたスキーマ管理が一般的ですが、本記事では理解のしやすさを重視してシンプルな SQL を使用します。

# Turso Shell でスキーマを適用
turso db shell my-edge-app-db
-- ユーザーテーブル
CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- 投稿テーブル(例:ブログアプリの場合)
CREATE TABLE IF NOT EXISTS posts (
  id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  published INTEGER NOT NULL DEFAULT 0,
  user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- インデックスの作成(パフォーマンス最適化)
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published, created_at DESC);

SQLite(および libSQL)では UUID 型が存在しないため、TEXT 型で UUID を格納するのがベストプラクティスです。randomblob(16) を使った UUID 生成はネイティブで効率的です。


Step 3:Turso クライアントの実装

環境変数の設定

# .env.local(ローカル開発用、git に追加しないこと)
TURSO_DATABASE_URL=libsql://my-edge-app-db-<your-org>.turso.io
TURSO_AUTH_TOKEN=eyJhbGciOiJFZERTQSJ9...

.gitignore.env.local が含まれていることを必ず確認してください。本番の認証トークンがリポジトリに混入することは、セキュリティ上の重大なリスクとなります。

データベースクライアントの実装

// src/lib/db.ts
import { createClient } from "@libsql/client/http";

// Edge Runtime では TCP 接続が制限されるため、HTTP ドライバーを明示的に使用
function createDbClient() {
  const url = process.env.TURSO_DATABASE_URL;
  const authToken = process.env.TURSO_AUTH_TOKEN;

  if (!url || !authToken) {
    throw new Error(
      "TURSO_DATABASE_URL と TURSO_AUTH_TOKEN の環境変数が設定されていません"
    );
  }

  return createClient({
    url,
    authToken,
  });
}

// シングルトンパターン(Edge Runtime ではリクエストごとに新規作成が必要な場合がある)
export const db = createDbClient();

データアクセス層の実装

// src/lib/repositories/post.repository.ts
import { db } from "@/lib/db";

export type Post = {
  id: string;
  title: string;
  content: string;
  published: number;
  user_id: string;
  created_at: string;
  updated_at: string;
};

export async function getPublishedPosts(): Promise<Post[]> {
  const result = await db.execute({
    sql: `
      SELECT p.*, u.name as author_name
      FROM posts p
      JOIN users u ON p.user_id = u.id
      WHERE p.published = 1
      ORDER BY p.created_at DESC
      LIMIT 20
    `,
    args: [],
  });

  return result.rows as unknown as Post[];
}

export async function getPostById(id: string): Promise<Post | null> {
  const result = await db.execute({
    sql: "SELECT * FROM posts WHERE id = ? AND published = 1",
    args: [id],
  });

  if (result.rows.length === 0) return null;
  return result.rows[0] as unknown as Post;
}

export async function createPost(data: {
  title: string;
  content: string;
  userId: string;
}): Promise<Post> {
  // プリペアドステートメントによる SQL インジェクション対策
  const result = await db.execute({
    sql: `
      INSERT INTO posts (title, content, user_id)
      VALUES (?, ?, ?)
      RETURNING *
    `,
    args: [data.title, data.content, data.userId],
  });

  return result.rows[0] as unknown as Post;
}

Step 4:Next.js の Route Handler と Server Actions の実装

API Route の実装(App Router)

// src/app/api/posts/route.ts
export const runtime = "edge";

import { NextRequest, NextResponse } from "next/server";
import { getPublishedPosts, createPost } from "@/lib/repositories/post.repository";

export async function GET() {
  try {
    const posts = await getPublishedPosts();
    return NextResponse.json({ posts }, { status: 200 });
  } catch (error) {
    console.error("投稿の取得に失敗しました:", error);
    return NextResponse.json(
      { error: "内部サーバーエラーが発生しました" },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { title, content, userId } = body;

    // 入力バリデーション
    if (!title || !content || !userId) {
      return NextResponse.json(
        { error: "title, content, userId は必須項目です" },
        { status: 400 }
      );
    }

    const post = await createPost({ title, content, userId });
    return NextResponse.json({ post }, { status: 201 });
  } catch (error) {
    console.error("投稿の作成に失敗しました:", error);
    return NextResponse.json(
      { error: "内部サーバーエラーが発生しました" },
      { status: 500 }
    );
  }
}

Server Component でのデータフェッチ

// src/app/page.tsx
export const runtime = "edge";

import { getPublishedPosts } from "@/lib/repositories/post.repository";
import { PostCard } from "@/components/PostCard";

// ISR の設定(必要に応じて)
export const revalidate = 60; // 60秒ごとに再検証

export default async function HomePage() {
  const posts = await getPublishedPosts();

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">最新の投稿</h1>
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </main>
  );
}

Step 5:Cloudflare Pages へのデプロイ

package.json のスクリプト設定

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "build:cf": "npx @cloudflare/next-on-pages",
    "preview": "npm run build:cf && wrangler pages dev .vercel/output/static",
    "deploy": "npm run build:cf && wrangler pages deploy .vercel/output/static"
  }
}

wrangler.toml の設定

# wrangler.toml
name = "my-edge-app"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

pages_build_output_dir = ".vercel/output/static"

nodejs_compat フラグは、Cloudflare Workers で Node.js 互換 API を有効にする重要な設定です。@libsql/client の HTTP ドライバーが内部で使用する fetchcrypto などの API が正常に動作するために必要となります。

Cloudflare Dashboard からの設定

  1. Cloudflare Dashboard にログイン
  2. Pagesプロジェクトを作成Git に接続
  3. GitHub / GitLab リポジトリを選択
  4. ビルド設定を以下のように入力:
項目
フレームワークプリセット Next.js
ビルドコマンド npx @cloudflare/next-on-pages
ビルド出力ディレクトリ .vercel/output/static
  1. 環境変数 タブで以下を追加:
変数名 環境
TURSO_DATABASE_URL libsql://... 本番 / プレビュー
TURSO_AUTH_TOKEN eyJ... 本番 / プレビュー
NODE_VERSION 18 本番 / プレビュー
  1. 保存してデプロイ をクリック

Step 6:本番環境のベストプラクティス

環境ごとの Turso データベース分離

本番環境・プレビュー環境・ローカル開発環境でデータベースを分離することは、実際の開発現場における基本的なベストプラクティスです。

# 本番用 DB
turso db create my-edge-app-db-prod --location nrt

# ステージング/プレビュー用 DB
turso db create my-edge-app-db-staging --location nrt

# 各環境のトークンを個別に発行
turso db tokens create my-edge-app-db-prod
turso db tokens create my-edge-app-db-staging

Cloudflare Pages では、本番ブランチ(例:main)とプレビューブランチ(その他すべて)で異なる環境変数を設定できます。この機能を活用して、ブランチごとに適切なデータベースに接続するよう設定しましょう。

データベースマイグレーション戦略

本番環境でのスキーマ変更は慎重に行う必要があります。ベストプラクティスとして、以下のアプローチを推奨します。

# マイグレーションファイルの管理(例)
migrations/
  001_initial_schema.sql
  002_add_tags_table.sql
  003_add_post_slugs.sql
# マイグレーションの適用スクリプト(package.json に追加)
# "migrate": "turso db shell $TURSO_DATABASE_URL < migrations/latest.sql"

Turso は現時点(2024 年時点)で公式のマイグレーションツールを提供していないため、Drizzle ORM の drizzle-kit を libSQL ドライバーと組み合わせて使用するか、独自のマイグレーション管理スクリプトを用意することが一般的なアプローチです。

キャッシュ戦略の最適化

Edge Runtime でのキャッシュは、パフォーマンスに直結する重要な要素です。

// src/app/api/posts/route.ts
export async function GET() {
  const posts = await getPublishedPosts();

  return NextResponse.json(
    { posts },
    {
      status: 200,
      headers: {
        // Cloudflare CDN による 60 秒のキャッシュ
        "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
      },
    }
  );
}

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

本番環境では、エラーの詳細をクライアントに返すことはセキュリティリスクとなります。適切なエラーハンドリングを実装しましょう。

// src/lib/errors.ts
export class DatabaseError extends Error {
  constructor(
    message: string,
    public readonly originalError?: unknown
  ) {
    super(message);
    this.name = "DatabaseError";
  }
}

// エラーログの構造化(Cloudflare Workers Logs で検索しやすくなる)
export function logError(error: unknown, context: Record<string, unknown>) {
  console.error(
    JSON.stringify({
      timestamp: new Date().toISOString(),
      error: error instanceof Error ? error.message : String(error),
      ...context,
    })
  );
}

Step 7:パフォーマンス監視と最適化

Cloudflare Analytics の活用

Cloudflare Dashboard の Analytics タブでは、以下の指標をリアルタイムで確認できます。

  • リクエスト数とキャッシュヒット率:キャッシュ設定の効果を検証
  • レスポンスタイム分布:P50 / P95 / P99 のレイテンシ分布
  • エラー率:4xx / 5xx エラーの発生状況
  • PoP ごとのトラフィック分布:どのエッジロケーションが多く使われているか

Turso のパフォーマンス確認

# クエリの実行プランを確認(最適化に活用)
turso db shell my-edge-app-db-prod

sqlite> EXPLAIN QUERY PLAN
   ...> SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC;

クエリが TABLE SCAN になっている場合はインデックスが効いていない可能性があります。EXPLAIN QUERY PLAN の結果で SEARCHUSING INDEX が表示されるようにインデックスを設計することがベストプラクティスです。

Web Vitals の計測

// src/app/layout.tsx に追加
export const metadata = {
  title: "My Edge App",
  description: "Next.js × Cloudflare × Turso のサンプルアプリ",
};

Cloudflare Pages は自動的に Web Analytics を提供しています。Dashboard の Web Analytics から Core Web Vitals(LCP・FID・CLS)を確認し、継続的な改善のベースラインとして活用してください。


トラブルシューティング

よくあるエラーと対処法

Error: The default export of the module should be a Response object...

このエラーは、Edge Runtime で Node.js 固有の API を使用したときに発生します。

// NG: Node.js の fs モジュールは Edge Runtime で使用不可
import { readFileSync } from "fs";

// OK: Web Standards API の fetch を使用
const response = await fetch("https://api.example.com/data");

LibsqlError: url is not specified

環境変数が正しく設定されていない場合に発生します。Cloudflare Pages の環境変数設定を再確認し、本番環境とプレビュー環境の両方に設定されているかを確認してください。

Too Many Connections エラー

Edge Runtime では各リクエストが独立した実行コンテキストで動作するため、コネクションプールの仕組みが従来の Node.js とは異なります。@libsql/client/http を使用することで、HTTP ベースのステートレスな接続を行い、この問題を回避できます。

デプロイ後に Static Assets が 404 になる

# wrangler.toml に追加
[site]
bucket = ".vercel/output/static"

@cloudflare/next-on-pages の出力先と wrangler.toml の設定が一致しているか確認してください。


本番運用チェックリスト

デプロイ前に以下の項目を確認することを推奨します。

  • すべての Route Segment に export const runtime = 'edge' が宣言されている
  • 本番用と開発用のデータベースが分離されている
  • 環境変数に機密情報が含まれ、リポジトリにコミットされていない
  • エラーハンドリングが実装され、詳細なエラー情報がクライアントに漏れない
  • 適切な Cache-Control ヘッダーが設定されている
  • データベースのインデックスが設計されている
  • マイグレーション戦略が確立されている
  • Cloudflare Analytics が有効になっている
  • レート制限(Cloudflare Rules)が設定されている

まとめ

本記事では、Next.js × Cloudflare × Turso の組み合わせで本番環境を構築する手順を段階的に解説しました。

このスタックの最大の強みは、インフラの複雑さを最小化しながら、グローバルな低レイテンシを実現できる点です。従来であればマルチリージョン展開に多大なコストと工数がかかっていた作業が、Cloudflare のエッジネットワークと Turso の分散レプリカによって劇的に簡素化されます。

実際の開発現場では、今回紹介したシンプルな構成をベースに、認証(Cloudflare Access / NextAuth.js)、バックグラウンドジョブ(Cloudflare Queues)、ファイルストレージ(Cloudflare R2)などを組み合わせてアプリケーションを発展させることができます。

エッジネイティブな開発の世界は日々進化しています。Cloudflare の公式ブログや Turso の変更履歴を定期的にチェックし、最新のベストプラクティスを取り入れていきましょう。

関連記事

AIエージェント拡張の標準規格「Agent Plugins」とは?Vercel主導の新標準とClaude未対応が意味するもの

AIエージェント拡張の標準規格「Agent Plugins」とは?Vercel主導の新標準とClaude未対応が意味するもの

AIエージェント拡張の標準規格「Agent Plugins」とは?Vercel主導の新標準とClaude未対応が意味するもの 「便利なAgent Skillを作ったのに、Cursor用・VS Code用・Claude用に3回パッケージし直した」——AIエージェントを活用している開発者なら、一度はこの痛みを経験したことがあるのではないでしょうか。 2026年8月6日、その構造的な問題を解決しようとす...

Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順

Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順

Webサービスのシャットダウン実装完全ガイド|新規停止から410 Goneまで、5フェーズで安全に閉じる手順 はじめに:サービスを「止める」のは、作るより難しい 「ドメインを解約したから終わり」と思っていませんか? 実際の開発現場では、ドメインを落としただけではサービスは完全に終わっていません。Stripeのサブスクリプションが生き続け、ユーザーへの課金が継続したまま——そういった事故は珍しくない...

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌

Mojo 1.0が正式リリース|Pythonのように書き、C++のように動くAI向け言語の全貌 メタディスクリプション: Chris Lattner率いるModularのAI特化言語「Mojo」がついに1.0に到達。Pythonとの互換性、圧倒的なパフォーマンス、1.0での変更点、非同期・パターンマッチングを含む今後のロードマップまで、AI/MLエンジニア目線で解説します。 --- AIエンジニア...

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計

Amazon EKS の HPA が最大40倍高速に|Provisioned Control Plane で変わるスケーリング設計 フラッシュセールが始まった瞬間、ダッシュボードのエラーレートが急上昇する。「HPA は設定済みのはずなのに、なぜ Pod がまだ増え始めていないのか」——本番運用をしているエンジニアなら、一度は経験したことのある焦りです。 2026年7月、AWS はそのボトルネックに...

コメント

0/2000