prodnote.dev本番ノート

Index/03

記事サイトをVPSに載せるのをやめて、Next.jsの静的エクスポートをCloudflare Pagesに置いた

稼働中のVPSに2つ目のサイトを同居させようとして、メモリと既存nginxの設定変更がリスクになると分かった。Next.jsのoutput:exportで静的化してCloudflare Pagesに置くまでの構成と、ビルドで踏んだ2つのエラーの記録。

Published
Tags
Next.jsCloudflare Pages個人開発

技術記事を書くサイトを新しく立てることにしました。 最初は、すでに動いている ConoHa VPS 2GB に同居させるつもりでした。サーバーはもうあるので、追加費用はゼロで済みます。

結論から書くと、その案はやめました。静的サイトとして書き出して、Cloudflare Pages に置いています。 この記事は、その判断の理由と、実際に構築してビルドが通るまでの記録です。

VPS同居をやめた理由

同居させる場合にやることを具体的に並べたら、思ったより重い作業でした。

やること何が問題か
2つ目のスタックを立てる2GB のうち既存の Postgres + Django + Next.js + nginx で大半を使っている
既存 nginx に server_name を追加稼働中サイトの設定ファイルを触ることになる
TLS 証明書を追加発行certbot の設定変更。ここも既存サイトと共用
Compose プロジェクト間の通信別プロジェクトのコンテナに到達するには共有ネットワークが要る

一番引っかかったのは2行目です。新しいサイトを追加する作業が、既存サイトを落とす可能性を持っている。 新しいほうはまだ何の価値も生んでいないのに、動いているほうにリスクを負わせる交換になっています。

そもそも、この用途に動的なサーバーが要りません。ひとりで書く Markdown の記事が数十本あるだけです。 データベースも管理画面もログイン機能も使いません。

構成

content/posts/*.md      ← 記事の実体。git 管理
  ↓ ビルド時に読む
Next.js (output: "export")
  ↓ 静的HTMLを吐く
Cloudflare Pages        ← 配信

使っているものは以下だけです。

  • Next.js 15.5(App Router、output: "export"
  • gray-matter(frontmatter の解析)
  • react-markdown + remark-gfm + rehype-highlight
  • Tailwind CSS

Web フォントは読み込んでいません。日本語の Web フォントはサブセット化しても初回表示に効きますし、 差し替え時のレイアウトずれの原因にもなります。表示速度の話を書くサイトが重いのも据わりが悪いので、OS 標準フォントで組みました。

output: "export" で踏んだエラー2つ

1. metadata route に force-static の明示が要る

app/robots.tsapp/sitemap.ts を置いたままビルドすると、こう落ちます。

Error: export const dynamic = "force-static"/export const revalidate not configured
on route "/sitemap.xml" with "output: export".

両方のファイルに1行足せば通ります。

export const dynamic = "force-static";

ついでに、sitemap の lastModifiednew Date() を書くのはやめました。 中身が変わっていないのにビルドのたびに更新日が新しくなるのは、検索エンジンに対して正確ではありません。 記事の updated(無ければ date)を入れています。

2. YAML は引用符なしの日付を Date オブジェクトにする

frontmatter の検証で、正しく書いたはずの記事がビルドを落としました。

date: 2026-08-13 # ← これは文字列ではなく Date オブジェクトになる

gray-matter の内部で js-yaml が timestamp 型として解釈するためです。 typeof value === "string" だけで検証していると、正しい frontmatter が弾かれます。

両方を受け付けて文字列に正規化しました。

function normalizeDate(value: unknown): string | null {
  if (value instanceof Date && !Number.isNaN(value.getTime())) {
    // YAML は UTC 0時として解釈するので、先頭10文字で元の表記に戻る
    return value.toISOString().slice(0, 10);
  }
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
    return value.trim();
  }
  return null;
}

frontmatter の不備でビルドを落とす

titledescription が空のまま公開してしまう事故を防ぎたかったので、記事の読み込み時に検証してエラーを投げています。

  • title / description / date が無ければビルドを止める
  • 本文の先頭に # 見出しを書いていたら止める(テンプレート側の h1 と二重になるため)

公開してから「meta description が空だった」と気づくより、 ビルドが通らないほうがよほど安全です。

URLは1記事1つに固定する

/posts/<slug>/ の1本だけにしました。同じ内容に到達できる URL を複数作らないようにしています。

trailingSlash: true を入れて末尾スラッシュの有無による表記ゆれも潰し、サイトマップにも正規 URL しか載せていません。

まとめ

  • 動的なサーバーが要らない用途に、動的なサーバーを用意しない
  • 新しいものを足す作業が、動いているものを壊す設計にしない
  • ビルドを通すこと自体をチェック機構として使う

Cloudflare Pages の無料枠で動いていて、既存の VPS には一切影響していません。