個人ブログを super.so から Astro 6 + Cloudflare Pages に移行した

目次

これはなに

  • このブログ (naoki11o.com) を super.so (Notion ベースのホスティング) から Astro 6 + Cloudflare Pages に再構築した話です
  • 年間コストは $169 → $10.44 (94% 削減) になり、コンテンツ管理は Notion から GitHub (Markdown) に移りました
  • 「super.so の年額をなんとかしたい」「Notion ロックインから抜けたい」と思っている個人ブロガーの方には参考になるかと思います

はじめに

n_11oです。

このブログは数年前に立ち上げて以来、Notion で記事を書いて super.so で公開する構成でゆるく運用してきました。Notion の編集体験はとても気に入っていたので、長らく大きな不満はなかったのですが、年間のサブスクリプションを棚卸ししているときに、改めて「super.so に年 $144 も払っているのか」と気になってしまい、腰を上げました。

やる前の構成と不満

移行前の構成と年額はこんな感じでした。

項目状態年額
ドメインSquarespace (旧 Google Domains から引き継ぎ)$25
ホスティングsuper.so$144
コンテンツソースNotion DB + 独立ページ 3 枚-
合計$169

不満は主に 3 つありました。

  1. 年 $144 はホスティング料金として割高に感じる(個人ブログにこの額は要らない)
  2. Notion ロックインが気になる。コンテンツソースをベンダーに預けている状態が続くと、サービス停止時に全部飛ぶリスクがある
  3. super.so の設定画面(カスタムドメイン、フォント、埋め込み等)は独自 UI でラップされていて、細かい調整がやりにくい

補足しておくと、このブログは記事数 30 本・記事あたり画像 3 枚ほど・日本語のみ・URL パターンは //post/<slug> のみ、というかなり軽量なブログです。

新構成を決める

要件を整理するとこのようになりました。

  • Must: ホスティング料金をゼロにする / コンテンツソースから Notion を外す / UI・コードの修正を GitHub リポジトリ経由でやる運用にする
  • Want: ドメイン更新料も可能なら下げる

調べた結果、最終的な構成はこうなりました。

項目BeforeAfter年額
SSG (Static Site Generator)— (super.so が内製)Astro 6 (Tailwind 4 + MDX)$0
ホスティングsuper.soCloudflare Pages (帯域無制限・完全無料)$0
ドメインSquarespaceCloudflare Registrar (Squarespace から移管)$10.44
DNSSquarespace / Cloudflare 混在Cloudflare (同一プロバイダに集約)$0
コンテンツソースNotion.mdx ファイルを GitHub で管理$0
合計$169$10.44

なぜ Astro (Next.js / Hugo ではなく)

コンテンツ特化の SSG (Static Site Generator) で、個人ブログには過不足のない機能性が決め手でした。

  • 30 記事のブログに Next.js はオーバースペック
  • Tailwind 4 / MDX / Content Collections が標準装備で、追加の設定がほとんど要らない
  • Cloudflare Pages でのビルドが 25 秒程度で終わる軽快さ
  • Astro Micro という完成度の高い個人ブログテーマがあり、デザインの叩き台にしやすい

なぜ Cloudflare Pages (Vercel ではなく)

  • 帯域幅が完全無料・無制限で、個人ブログで容量超過を気にする必要がない
  • DNS を同じ Cloudflare に置けば、ネームサーバー変更・プロキシ設定が一箇所で完結する
  • wrangler を使わず、純粋な static site として扱えるので設定がシンプル

やったこと

大きく 4 ステップで進めました。

1. Notion → Markdown (MDX) への一括移行

Notion DB と独立ページの合計 30 記事を、Notion API 経由で順に .mdx ファイルとして書き出しました。画像も OG 画像・スクショ・Twitter 画像など 90 枚ほどを、Notion のフルサイズ URL から連番 (01.jpg, 02.jpg, …) で src/assets/images/<slug>/ にダウンロード。

NOTE

2025 年に新しくなった Notion API (2025-09-03) は「Data Sources」の概念が入って、databases.query ではなく dataSources.query を使う設計になっています。@notionhq/client v5 はこれベース。旧版 (2022-06-28) と混在するとハマるポイント。

移行スクリプトは scripts/remigrate.mjs として残してあり、今後 Notion 側で記事が増えたり大幅に修正された場合にも再利用できるようにしています(普段の執筆は GitHub 側で完結するので基本は使わない想定ですが、念のため)。

2. 既存 URL (/post/<slug>) の完全維持

Astro 側で trailingSlash: 'never' + build.format: 'file'/post/<slug>.html を出力することで、既存の外部リンクを一切壊さないようにしました。

// astro.config.mjs
export default defineConfig({
  site: 'https://naoki11o.com',
  trailingSlash: 'never',
  integrations: [embeds(), mdx(), sitemap()],
  build: { format: 'file' },
  // ...
});

移行後に sitemap.xml から全スラッグを抽出して、旧本番と新本番で HTTP 200 チェックをかけるスクリプトを回して全記事生存を確認しました。

3. Cloudflare Pages へのデプロイとカスタムドメイン接続

GitHub に push すると Cloudflare Pages が自動でビルド → デプロイする構成にしました。Custom domain 設定で、既存の super.so 用 A レコード (76.76.21.21) を削除し、naoki11o-blog.pages.dev への CNAME に切り替えています。

4. ドメインを Squarespace → Cloudflare Registrar に移管

原価ベース (年 $10.44) で更新できるようにするため、Cloudflare Registrar へ移管。Squarespace の Auth Code 発行フローが若干トラブルがあったものの、最終的には無事完了しました。

今のリポジトリ構成(抜粋)

最終的に以下のような構成に落ち着きました。

src/
├── content.config.ts       # 記事 frontmatter の Zod スキーマ
├── content/
│   └── posts/              # 記事本体 (*.mdx)
├── assets/
│   └── images/<slug>/      # 記事画像
├── lib/
│   └── getPosts.ts         # 公開記事の取得ロジック (draft フィルタ)
├── layouts/
│   ├── BaseLayout.astro    # 共通 head / header / footer + theme toggle
│   └── PostLayout.astro    # 記事詳細ページのレイアウト
├── components/
│   ├── PostCard.astro      # 記事リスト行
│   ├── TableOfContents.astro  # h2/h3 が 2 つ以上で自動表示
│   └── ShareButtons.astro  # X / Facebook シェアボタン
└── pages/
    ├── index.astro         # トップ (年別記事一覧)
    ├── post/[slug].astro   # 記事詳細動的ルート
    ├── tags/
    ├── about.astro
    ├── search.astro
    └── rss.xml.js

scripts/
├── remigrate.mjs           # 初回の Notion → MDX 移行
├── sync-note.mjs           # note.com RSS → MDX 自動同期 (後述)
├── audit-mdx.mjs           # 移行で出た MDX 崩れチェック
└── audit-style.mjs         # 文体ガイド違反の静的チェック

frontmatter スキーマ

src/content.config.ts で Zod を使って定義しています。

const posts = defineCollection({
  loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      publishedAt: z.coerce.date(),
      updatedAt: z.coerce.date().optional(),
      tags: z.array(z.string()).default([]),
      heroImage: image().optional(),
      draft: z.boolean().default(false),
      source: z.enum(['note']).optional(),
      sourceUrl: z.string().url().optional(),
    }),
});

これで frontmatter の型がビルド時に検証されるようになり、入力ミスを早期に検知できるようになりました。sourcesourceUrl は後から note.com 自動取り込み のために足したフィールドで、こうした拡張を schema-safe に足せるのも helpful でした。

下書き (draft) のワークフロー

src/lib/getPosts.tsdev サーバー時は draft を含めて表示、本番ビルド時は除外 する分岐を入れています。

export async function getPublishedPosts() {
  return await getCollection('posts', ({ data }) => {
    if (import.meta.env.DEV) return true; // ローカルは draft も見える
    return !data.draft;                   // 本番は draft を除外
  });
}

これで「書きかけの記事を commit/push しても本番には出ない、でも npm run dev でプレビューはできる」という体験になりました。

結果

指標BeforeAfter
年間コスト$169$10.44 (-94%)
ホスティングsuper.so ($144)Cloudflare Pages ($0)
デプロイ時間Notion 更新 → 数分git push → Cloudflare Pages が 25 秒でビルド
コンテンツソースNotion.mdx ファイル (GitHub で管理)
URL/post/<slug>/post/<slug> (完全維持)
GA4計測中計測中 (引き継ぎ)
下書きワークフローNotion 上のチェックボックスdraft: true で本番ビルドから除外

コスト面以外の副次効果として、「記事の修正がブランチ切って PR を作って merge、という普段の開発フローと完全に一致した」のがかなり大きいと感じています。ローカルの VSCode で書いて即プレビューし、タイポ修正も 30 秒で反映できる。

移行後に作った追加機能

Astro + Cloudflare Pages + GitHub のスタックになったことで、これまで super.so + Notion では手を出しづらかったカスタマイズが一気に現実的になりました。ついでにやった中で個人的に満足度が高かった 2 つを紹介します。

note.com の記事を自動でブログにも転載する

私は note.com にもときどき記事を書くのですが、これまでは「naoki11o.com に書く or note.com に書く」のトレードオフがあって、note に書いた記事は naoki11o.com の記事一覧には出てこない状態でした。

そこで、note に書いたら自動的にブログ側にもエントリが作られる運用を作りました。月次 cron + 手動トリガーの GitHub Actions です。

月に 1 回 (cron: 0 3 1 * *) GitHub Actions が起動

note の RSS (https://note.com/n_110709/rss) を取得

既存の MDX と重複判定 (frontmatter の source=note と noteId 一致)

新規 note 記事があれば src/content/posts/note-<noteId>.mdx を生成

peter-evans/create-pull-request で PR を自動作成

私が PR を確認して merge → 本番反映

一覧・詳細の両方で、note 由来の記事には note ↗ の pill バッジを付けて区別できるようにしてあります。詳細ページでは pill をクリックすると note.com の元記事に新しいタブでジャンプする導線です。

NOTE

当初は PR 自動 merge まで含めて完全自動化する想定でしたが、GitHub の auto-merge は Pro / Team プラン以上でしか private リポジトリで有効にできないことが発覚して手動 merge 運用に落ち着きました。月 1 回のクリック作業なので許容範囲と判断。

重複判定ロジックはちょっと工夫していて、「本文中に note URL が出現しているか」ではなく「frontmatter の source: note + sourceUrl で同じ noteId を持つ記事があるか」で判定しています。

前者の方針だと、自分の過去 note を他の記事内で引用している(埋め込みカードとして表示しているだけ)ケースを「もう存在する」と誤判定して、バックフィルで取り込み漏れが出る可能性がありました。後者の方針ならその罠を避けられて、手動で作ったスタブと自動生成スタブの両方で同じマーカーを運用できます。

初回のバックフィルで過去 17 件を一気に取り込み、現在は 25 記事が note 同期分としてブログに並んでいます。

GA4 と Search Console の週次・月次レポートを自動生成

super.so には built-in で簡単なアクセス統計がついていたのですが、自前ホスティングに移行すると当然そういう機能は無くなります。GA4 と Search Console のダッシュボードをブラウザで見ればよいのですが、毎週・毎月ブラウザで開いて眺めるのはあまり続かなさそうだったので、別リポジトリ (naoki11o-analytics) に Python の小さい CLI を作りました。

  • 毎週月曜朝 9 時 (JST): 先週 (月〜日) の PV / Session / 人気記事 Top 10 / 流入元 の週次レポート
  • 毎月 1 日朝 9 時 (JST): 先月の同等の月次レポート

これらを GitHub Actions 上で cron 実行し、Markdown にして reports/ ディレクトリに auto-commit → Slack にも通知という仕組みです。

# GA4 は GOOGLE_APPLICATION_CREDENTIALS を自動参照
client = BetaAnalyticsDataClient()
response = client.run_report(
    property="properties/272830016",
    dimensions=[Dimension(name="pagePath"), Dimension(name="pageTitle")],
    metrics=[Metric(name="screenPageViews")],
    date_ranges=[DateRange(start_date=start, end_date=end)],
)

認証は GCP サービスアカウントの JSON キーを GitHub Secret (SERVICE_ACCOUNT_JSON) に入れ、workflow 内でファイル復元 → GOOGLE_APPLICATION_CREDENTIALS に指定する方式。

WARNING

GA4 のアクセス管理は GCP IAM と完全に別系統で、GCP 側でサービスアカウントにロールを付けても GA4 は読めません。GA4 管理画面側で個別に「閲覧者」としてサービスアカウントを追加する必要があります。Search Console も同様。

副次効果として、週次レポートをそのままブログ改善のインプットにできるようになりました。たとえば先日のパフォーマンスチューニング記事 (/post/blog-perf-tuning) でも、この週次レポートから Top 10 記事を引いてきてベンチマーク対象にしています。

ハマったポイント(抜粋)

技術的に痛かった箇所を 4 つだけピックアップして残しておきます。

1. Notion callout の子要素を忘れて本文が消える

初回の移行で、Notion の callout ブロック (> [!NOTE] 相当) を変換するときに block.children の再帰 fetch を忘れ、ラベル部分 (例: < 主なやったこと >) だけ残って本文 (箇条書き) が全部消える事故が起きました。

「なんか記事スカスカじゃない…?」とふと気付いて発覚。has_children: true のブロックはすべて GET /blocks/<id>/children を叩いて中身を展開する必要がある、という基本を忘れていたオチでした。

再移行用の scripts/remigrate.mjs を書いて全 30 記事を再取得することで救済しました。

2. astro-embed の URL 自動カード化は単独行 URL にしか効かない

Notion の bookmark ブロックはデフォルトで [URL](URL) という Markdown リンク形式で書き出されますが、astro-embed の自動変換は前後に空行がある単独行の URL にしか反応しない仕様です。

そのため、Notion → MDX 変換後に「単独行 URL へ書き直す post-process」を挟むことで解決しました。

3. MDX での <, > エスケープ忘れ

Notion の記事内に <インタビュー前の仮説> のような山括弧表現があると、MDX はそれを JSX の開始タグとして解釈しようとしてビルドが失敗します。

Error: Expected a closing tag for <インタビュー前の仮説>

escapeMdx() を噛ませて < > { } \ をすべて escape しました(ただしコードブロック内は除外)。

学び・所感

  • 年額サブスクは定期的に棚卸しするとよい。今回は年 $200 近い出費を $10 にできた。移行作業は半日〜1日程度なので費用対効果は大きい
  • コンテンツを文字ファイルとして手元に持てる安心感はお金に換えられない。ベンダーロックから出ると、「サービス停止したらどうする」系の不安が消える
  • 既存 URL の維持は最優先にすべき。SEO や SNS シェアリンクが全部生きるので、ここを妥協しないで済む構成を選ぶ価値は大きい
  • 移行後は派生の改修がしやすくなる。note 自動取り込みや analytics レポートの自動化など、「ブログを触りたい」と思ったときに普通に開発フローで手を入れられるのが気持ちよい

おわりに

移行して半月ほど経ちますが、執筆からデプロイまでの体験が根本的に快適になり、加えて「気になったらすぐ改修できる」という自由度も手に入って、総合的に満足度の高い移行でした。

「note やはてなブログではなく独自ドメインのサイトで記事を書きたいな」と思っている方には、Astro + Cloudflare Pages の構成はかなり強いので、ぜひ検討してみてください。