• #SSG
  • #SSR
  • #Nuxt3
  • #Cloudflare Pages
  • #マルチテナント
  • #デプロイ
開発tax-lpメモ

背景

業種別のランディングページ(LP)を複数業種ごとに作っている。もともとSSR(サーバーサイドレンダリング)で動かす前提で設計していたが、今日SSG(静的サイト生成)に切り替えた。

なぜSSRにしていたかというと、サブドメインで業種を判定する仕組み(industryA.example.com → 業種A向けコンテンツ)を使っていて、サブドメインの読み取りにはサーバー側の処理が必要だったから。ローカル開発でも localhost ひとつ立ち上げれば複数業種を切り替えて確認できる、という開発上の利便性もあった。

SSGに切り替えた理由

SEO的にはSSRもSSGも差がない。どちらもHTMLが事前に生成されるので、クローラーから見れば同じこと。

SSRを続ける場合、Cloudflare Workers上でNuxt Contentを動かすためにD1(CloudflareのSQLiteサービス)が必要になる。LPサイトは更新頻度が低い静的コンテンツが中心なので、わざわざD1をセットアップして維持する意味がない。

判断のポイントはシンプルさだった。D1のバインディング設定、Workers Paidプランの管理、データベースのマイグレーション -- これらの運用コストを考えると、SSGで静的HTMLを生成してCloudflare Pagesに置くだけの方がずっとシンプル。複数業種分のビルドをワンコマンドで回せるなら、開発体験も変わらない。

実装の変更点

変更は実質3箇所だけだった。

1. useSubdomain composableの環境変数フォールバック

SSRではリクエストのHostヘッダーからサブドメインを読み取っていた。SSGではビルド時にHostヘッダーが存在しないので、環境変数 NUXT_PUBLIC_INDUSTRY から業種を取得するフォールバックを追加した。

// composables/useSubdomain.ts(修正後のイメージ)
export const useSubdomain = () => {
  const config = useRuntimeConfig()
  const event = useRequestEvent()

  // 1. 環境変数(SSGビルド時に使われる)
  const envIndustry = config.public.industry as string

  // 2. サーバーミドルウェアが設定したサブドメイン
  const subdomainFromEvent = event?.context?.subdomain as string

  // 3. フォールバック順序: 環境変数 → サブドメイン → デフォルト
  const industry = envIndustry || subdomainFromEvent || 'industryA'

  return { industry }
}

ここで重要だったのは ??(Nullish Coalescing)ではなく ||(OR)を使うこと。??nullundefined だけをスキップするが、空文字列 "" はそのまま通してしまう。環境変数が空文字列で設定されるケースがあるため、|| で falsy な値すべてをスキップする必要があった。

2. nuxt.config.tsのpreset変更

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    // SSR時: cloudflare_pages(Workers Runtime)
    // SSG時: cloudflare_pages_static(静的ファイルのみ)
    preset: 'cloudflare_pages_static'
  },
  runtimeConfig: {
    public: {
      industry: '' // NUXT_PUBLIC_INDUSTRY 環境変数から自動で上書きされる
    }
  }
})

cloudflare_pages から cloudflare_pages_static に変えるだけ。Nuxtの規約で、runtimeConfig.public.industry は環境変数 NUXT_PUBLIC_INDUSTRY から自動的にマッピングされるので、追加の設定は不要。

3. デプロイスクリプトの作成

各業種それぞれで環境変数を切り替えてビルドし、対応するCloudflare Pagesプロジェクトにデプロイするスクリプトを作った。PowerShellとBashの両方を用意した。

# scripts/deploy.ps1(簡略版)
param(
    [string]$Industry = ""
)

$industries = @(
    @{ name = "industryA"; project = "tax-lp-industryA" },
    @{ name = "industryB"; project = "tax-lp-industryB" },
    @{ name = "industryC"; project = "tax-lp-industryC" }
    # ... 各業種分を定義
)

# 引数があれば1業種だけ、なければ全業種
if ($Industry) {
    $targets = $industries | Where-Object { $_.name -eq $Industry }
} else {
    $targets = $industries
}

foreach ($ind in $targets) {
    Write-Host "[Build] $($ind.name)..."
    $env:NUXT_PUBLIC_INDUSTRY = $ind.name

    # キャッシュ削除 → ビルド → デプロイ
    Remove-Item -Recurse -Force .nuxt, .data, dist -ErrorAction SilentlyContinue
    npx nuxi generate
    npx wrangler pages deploy dist --project-name $ind.project --branch main
}

使い方は2パターン。

# 1業種だけ
powershell -ExecutionPolicy Bypass -File scripts/deploy.ps1 -Industry industryA

# 全業種まとめて
powershell -ExecutionPolicy Bypass -File scripts/deploy.ps1

Bash版も同じ構造で作った。

#!/bin/bash
# scripts/deploy.sh
INDUSTRIES=("industryA" "industryB" "industryC") # 各業種名を定義

for ind in "${INDUSTRIES[@]}"; do
    echo "[Build] $ind..."
    rm -rf .nuxt .data dist
    NUXT_PUBLIC_INDUSTRY=$ind npx nuxi generate
    npx wrangler pages deploy dist --project-name "tax-lp-$ind" --branch main
done

Cloudflare Pagesプロジェクトの作成

複数のプロジェクトをCLIから一括で作成した。

for ind in industryA industryB industryC; do
  npx wrangler pages project create "tax-lp-$ind" --production-branch main
done

GitHubとの連携は使わない方針にした。ローカルでビルドして wrangler pages deploy で直接デプロイする。理由は、GitHub Actionsのビルド設定を業種ごとに管理するよりも、手元で deploy.ps1 を叩く方がシンプルだから。LPサイトは頻繁に更新するものではないし、ビルド時間も短い。

ハマったところ

SSGプリレンダリング時のサブドメイン判定バグ

全業種を一通りデプロイした後、全サイトが業種Aのコンテンツになっていた。NUXT_PUBLIC_INDUSTRY=industryB でビルドしても結果は同じ。

原因は server/middleware/subdomain.ts にあった。SSGの nuxt generate 実行時にもサーバーミドルウェアが動作する。プリレンダリング時のHostヘッダーは localhost なので、ミドルウェアのフォールバックロジックが常にデフォルト業種を返していた。

そして useSubdomain.ts 側では、ミドルウェアが設定した値が環境変数より先に評価されていた。結果として、環境変数で別の業種を指定していても、ミドルウェアが先にデフォルト業種を入れてしまい、そちらが使われていた。

修正は2点。

  1. useSubdomain.ts の優先順位を変更 -- 環境変数を最優先にした
  2. ??|| に変更 -- 空文字列もフォールバックの対象にした

この修正後、各業種のコンテンツが正しく生成されるようになった。

--branch main の指定漏れ

Cloudflare Pagesでは、プロジェクト作成時に --production-branch main を指定しても、デプロイ時に --branch main を付けないとプレビューデプロイ扱いになる。本番URL(tax-lp-{業種名}.pages.dev)が404を返し、個別のデプロイURL(d1936697.tax-lp-{業種名}.pages.dev)は200を返すという状況だった。

wrangler pages deploy dist --project-name tax-lp-{業種名} --branch main のように、デプロイコマンドにも --branch main を明示する必要がある。

PowerShellからのwrangler実行問題

PowerShellから npx wrangler を実行すると Could not determine Node.js install directory というエラーが出ることがあった。Volta(Node.jsバージョン管理ツール)経由の wrangler がPowerShell環境でうまく動かないケースがある。

Git Bashからは問題なく動作したので、最終的にはBash版のデプロイスクリプトも用意して対応した。PowerShell版も $ErrorActionPreference = "Stop" を削除することで、stderrの出力を致命的エラーとして扱わないようにして改善した。

SQLiteロック

業種を連続でビルドする際、前のビルドプロセスが .data/contents.sqlite をロックしたままになることがあった。次の業種のビルドが「データベースがロックされている」とエラーを出す。

対策として、各ビルドの前に .nuxt.data ディレクトリを削除するステップを入れた。これはmdx-playgroundのデプロイスクリプトでも同様の対策をしていたので、同じアプローチを採用した。

最終的な構成

Cloudflare Pages プロジェクト:
  tax-lp-{業種名}  → {業種名}.example.com  (業種ごとにプロジェクトを作成)

デプロイフロー:
  ローカル → NUXT_PUBLIC_INDUSTRY=xxx nuxi generate → wrangler pages deploy → Cloudflare

GitHub連携なし。ローカルビルド、直接デプロイ。サーバーサイドの処理はゼロ。D1も不要。

今日の判断のまとめ

SSR → SSGの移行は「SSGでできるならSSGにしておく」という方針に基づいている。LPサイトに動的なサーバー処理は必要ない。環境変数でテナントを切り替え、業種ごとにビルドして静的ファイルをデプロイするだけ。

変更量は小さかった(composable 1ファイル、nuxt.config.ts 2行、デプロイスクリプト新規作成)が、SSGプリレンダリング時のミドルウェア動作という落とし穴があった。SSRで動いていたコードをSSGに持っていくときは、ビルド時にもサーバーミドルウェアが走ることを意識しておく必要がある。