0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vercel Hobby は非商用限定。販売ページだけ Next.js から静的書き出しして Cloudflare Workers に逃がした

0
Last updated at Posted at 2026-09-03

個人開発の有料プロダクトを売ろうとして、販売ページの置き場所で詰まりました。

アプリ本体は Next.js + Firebase で、Vercel の Hobby プランに載せています。同じプロジェクトの中に価格ページと特商法ページを作って、そのままデプロイすればいい——と思っていたのですが、Vercel の規約を読んでいて手が止まりました。

Hobby は「非商用の個人利用のみ」で、販売ページは商用に当たる

Vercel の Fair Use Guidelines にこう書いてあります。

Hobby teams are restricted to non-commercial personal use only. All commercial usage of the platform requires either a Pro or Enterprise plan.

問題は「商用」の定義が、思っていたよりずっと広いことでした。

Commercial usage is defined as any Deployment that is used for the purpose of financial gain of anyone involved in any part of the production of the project, including a paid employee or consultant writing the code.

そして例として挙がっているものの中に、これがあります。

  • Any method of requesting or processing payment from visitors of the site
  • Advertising the sale of a product or service
  • Affiliate linking is the primary purpose of the site
  • The inclusion of advertisements

決済を受けていなくても、「売っていることを告知する」時点で商用です。注記には寄付の依頼も商用に含まれると書かれています。つまり「まだ1円も売れていない販売ページ」も対象で、売上が立ってから引っ越せばいい、という話ではありませんでした。

Pro に上げれば済みますが、platform fee は $20/月です(1 デプロイシート+$20 のクレジット込み)。売上ゼロの段階の固定費としては重い。

アプリごと Cloudflare Workers に移す、はできなかった

では全部 Workers に移せばいいかというと、そこは塞がっていました。アプリ本体が firebase-admin に依存していて、これが Workers 上で動きません。理由は2つで、内部が XMLHttpRequest を前提にしていることと、Firestore クライアントが gRPC に依存していることです。

upstream のイシューは firebase/firebase-admin-node#2069「Various issues when used with Cloudflare Pages」で、2023年2月に立って、いまも open のままです(最終更新 2026-08-14、コメント41件)。3年半動いていないイシューの解決を前提に構成を決めるのは無理でした。

「売り物」と「売り場」を分けた

行き着いたのは、移すのはアプリではなく販売ページのほうだ、という整理でした。

  • アプリ本体(認証・請求書発行・管理画面)は Vercel Hobby のまま。これは自分の開発用で、誰にも売っていないので非商用
  • 販売ページ(/ /pricing /faq /legal/terms /legal/privacy /legal/tokushoho の6枚)だけを静的書き出しして Cloudflare Workers Static Assets に置く

販売ページは全部読むだけのページなので、firebase-admin を必要としません。商品のコードは1行も変えずに済みました。

静的化を止めていたのは共通ヘッダーだった

ところが next build に投げても、6ページとも静的にプリレンダされませんでした。

原因は共通の SiteHeader で、ログイン状態を出すために getSessionUser() を呼んでいたことです。これが1か所あるだけで、それを含む全ページが動的レンダリングに落ちます。販売ページにログインボタンは要らないので、MARKETING_ONLY という環境変数で分岐して、立っているときはセッションを読まないようにしました。

export async function SiteHeader() {
  const marketingOnly = process.env.MARKETING_ONLY === "1";
  const user = marketingOnly ? null : await getSessionUser();
  // ...
}

ここで一度踏んだのが、build にだけ env を渡して start に渡し忘れる、という間違いです。

MARKETING_ONLY=1 pnpm build
MARKETING_ONLY=1 PORT=3100 pnpm start   # ← こちらにも要る

ビルド時に静的化されている想定でも、1ページでも動的に戻ると start 側でヘッダーが再評価されてログインボタンが描画されます。書き出しの元になるのは start が返す HTML なので、両方に渡さないと混ざります。

next export ではなく、自前サーバから curl で吸い出した

アプリ本体と同じプロジェクトに同居しているので、プロジェクト全体を output: "export" にはできません。そこで、ローカルで next start したものから必要なページだけを取りにいく方式にしました。

fetch() {
  local path="$1" target="$2"
  mkdir -p "$(dirname "$OUT_DIR/$target")"
  curl -fsS "$BASE_URL$path" -o "$OUT_DIR/$target"
}

fetch "/"                "index.html"
fetch "/pricing"         "pricing/index.html"
fetch "/faq"             "faq/index.html"
fetch "/legal/terms"     "legal/terms/index.html"
fetch "/legal/privacy"   "legal/privacy/index.html"
fetch "/legal/tokushoho" "legal/tokushoho/index.html"
fetch "/robots.txt"      "robots.txt"
fetch "/sitemap.xml"     "sitemap.xml"
fetch "/opengraph-image" "og.png"

# 404 は 200 を返さないので -f を外す。
# wrangler.jsonc の not_found_handling: "404-page" がこれを参照する
curl -sS "$BASE_URL/__startkit_not_found__" -o "$OUT_DIR/404.html"

-f(--fail)を全部に付けていると、404 ページの取得でスクリプトごと落ちます。ここだけ外すのを忘れないでください。

取ってきた HTML には Next.js のランタイムを読む <script> が残っているので、これを除去します。残したままだとハイドレーションが走って、動くはずのないクライアント処理が動こうとします。

html
  // preload も消す。<script> だけ消すと <link as="script"> が残って取りにいく
  .replace(/<link\b(?=[^>]*\bas=["']script["'])(?=[^>]*\bhref=["']\/_next\/static\/chunks\/)[^>]*>/gi, "")
  .replace(/<script\b(?=[^>]*\bsrc=["']\/_next\/static\/chunks\/)[\s\S]*?<\/script>/gi, "")
  .replace(/<script\b(?![^>]*\bsrc=)[\s\S]*?<\/script>/gi, "")
  .replace(/\/opengraph-image\?[^"']+/g, "/og.png");

<script> を消しただけだと <link rel="preload" as="script"> が残り、ブラウザはチャンクを取りにいきます。実行はされないので画面は動きますが、静的サイトのつもりで 404 を出し続けることになるので、preload も一緒に落とします。

あわせて public/ をコピーするときに、PDF 生成専用の Noto Sans JP(4.5MB)を配信対象から外しています。静的サイトの HTML/CSS からは参照されないのに、そのままだと成果物の大半がフォントになります。

?? を || に変えないと、開発用の値が消せなかった

静的サイトはビルド時の環境変数を HTML に焼き込みます。ここが動的なサイトと決定的に違うところで、.env.local に入れていた開発用の事業者名がそのまま公開されうる、という問題が出ました。

本番用に .env.production.local を置いて上書きするわけですが、「値を空にして無効化する」ができませんでした。設定側が ?? を使っていたためです。

?? は null と undefined しか拾わないので、空文字はそのまま通ります。.env ファイルで値を打ち消す手段は空文字しかないので、|| に変えました。

const ON_REQUEST = "請求があれば遅滞なく開示します";

export function getLegalConfig(): LegalConfig {
  return {
    issuerName:   process.env.INVOICE_ISSUER_NAME     || ON_REQUEST,
    operatorName: process.env.INVOICE_ISSUER_OPERATOR || ON_REQUEST,
    issuerAddress: process.env.INVOICE_ISSUER_ADDRESS || ON_REQUEST,
    // ...
  };
}

?? のほうが安全、という一般則がそのまま当てはまらない場面でした。

フォールバック先を「請求があれば遅滞なく開示します」にしているのは、特商法11条ただし書き・省令10条により、販売業者の氏名・住所・電話番号(省令8条1号)は「請求があれば遅滞なく提供する旨」を広告に表示し、かつ実際に遅滞なく提供できる措置を講じていれば省略できるためです。空欄でもダミーでもなく、法令上有効な文言を既定にしておく。ただし後段の条件が実務では本体なので、この文言を出す以上は問い合わせに答えられる体制が要ります。

開発用の値のまま公開すると、特商法の表示が虚偽になる

日本で有料のものを売るなら、特定商取引法に基づく表記が要ります。ここに開発用のダミーが焼き込まれたまま公開されると、単なるバグではなく虚偽表示です。

なので、ビルドとデプロイの両方でプレースホルダを検査するようにしました。デプロイ側はこうです。

# 開発用の値が焼き込まれたまま公開すると特商法の表示が虚偽になる。ここで止める。
for pattern in "Dogfood" "事業者名未設定" "T0000000000000" "example.com"; do
  if grep -rq "$pattern" out-marketing --include="*.html" 2>/dev/null; then
    echo "中止: プレースホルダ '$pattern' が out-marketing/ に残っています。" >&2
    exit 1
  fi
done

exec pnpm dlx wrangler deploy

ビルド側は同じパターンに yourname を足したうえで、grep -rl で該当ファイルを一覧表示して警告だけ出して続行します。ビルドは何度も回すので止めると邪魔になりますが、公開は取り返しがつかないので、そちらは exit 1 で止める、という分け方です。

インボイスの登録番号は、登録されているときだけ行を出す形にしました。T0000000000000 のようなダミーを表示するくらいなら、行ごと無いほうが正しい。

まとめ

  • Vercel Hobby は非商用限定で、「売っていることを告知する」だけで商用に当たる。決済の有無ではない
  • 商用の定義は「プロジェクトの制作に関わった誰かの金銭的利益」まで含む、かなり広いもの
  • firebase-admin は Workers で動かない(XHR 前提・gRPC 依存)。upstream のイシューは3年半 open のまま
  • アプリを移せないなら、売り場だけ切り出す。販売ページは読むだけなので静的化できる
  • 静的化を止めているのは、たいてい共通ヘッダーのセッション参照。build と start の両方に env を渡す
  • 同居プロジェクトでは export が使えない。next start から curl で吸って <script> を落とす
  • 静的サイトはビルド時 env を焼き込む。?? では .env から値を打ち消せない
  • 特商法ページにダミーが焼き込まれるのは虚偽表示。デプロイは検査で止める

作ったもの

この構成は、日本の商習慣に合わせた Next.js スターターキット StartKit JP で実際に使っているものです。
認証・マルチテナント(Custom Claims + Firestore Security Rules)と特商法 / プラポリ / 利用規約のテンプレは、MIT ライセンスの無料版 として公開しています。

適格請求書ひな形の PDF 発行や、Stripe 日本円決済・消費税自動計算・管理画面は有料版に入っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?