wheatandcatの開発ブログ

技術系記事を投稿してます

静的サイトをNext.js × Vercel → Astro × Cloudflare Pagesに移行

概要

これまで静的サイトはだいたい Next.js × Vercel で作っていた。
ただ、カウカウ をリリースして以降、このサービスだけで Vercel の無料枠を使い切り、他サイトのデプロイに影響が出るようになっていた。

そこで、一部の静的サイトを Astro × Cloudflare Pages に移行したので、その内容をまとめる。
ついでに Astro を初めて触ってみたが、設計思想もなかなか面白かったのでその点も紹介する。

PR

github.com

Astroとは?

astro.build

Astro は静的サイト構築に適した Web フレームワーク。
Astro の流儀で開発すると、ビルド後は JavaScript を極力含まないシンプルな HTML になるのが特徴。

また今回は使っていないが、Astro は Islands architecture を採用しており、Astro の中にそのまま ReactPreactVueSvelte などのコンポーネントを import して使える。
必要な箇所だけ動的 UI を載せたい場合は、この仕組みを使って実装できる。

docs.astro.build

Astroでのコンポーネントの書き方

Vue の SFC に近い書き方で、1ファイルの中にスクリプト、HTML、CSS をまとめて表現できる。
たとえば以下のような形。

---
const title = 'Hello Astro';
---

<h1>{title}</h1>

<style>
  h1 {
    color: red;
  }
</style>

実装

Next.js → Astroへの移行

こういう移行作業は AI の得意分野なので、Claude Code に依頼して修正してもらった。
コンポーネントの差分は以下の通り。

■ (Before)src/components/organisms/Top/News.tsx

import React from "react";
import { FeedItem } from "@/lib/feed";
import dayjs from "dayjs";

type Props = {
  feedItems: FeedItem[];
};

const News: React.FC<Props> = (props) => (
  <div>
    <h1 className="text-2xl mb-7">Tech blog</h1>
    <div>
      <div className="my-2">
        <span className="bg-gray-300 py-1 px-2 text-xs font-bold">Hatena</span>
      </div>
      <div className="font-light">
        {props.feedItems
          .filter((v) => v.type === "hatena")
          .slice(0, 3)
          .map((v) => (
            <div key={v.title} className="mb-5">
              <div>{dayjs(v.createdAt).format("YYYY-MM-DD")}</div>
              <a href={v.url} target="_blank" rel="noopener noreferrer">
                {v.title}
              </a>
            </div>
          ))}
        <div className="pt-3 pb-8">
          <div className="text-xs">
            <a
              href="https://www.wheatandcat.me/"
              target="_blank"
              rel="noopener noreferrer"
            >
              もっと読む
            </a>
          </div>
        </div>
      </div>
      <div className="my-2">
        <span className="bg-gray-300 py-1 px-2 text-xs font-bold">Zenn</span>
      </div>
      <div className="font-light">
        {props.feedItems
          .filter((v) => v.type === "zenn")
          .map((v) => (
            <div key={v.title} className="mb-5">
              <div>{dayjs(v.createdAt).format("YYYY-MM-DD")}</div>
              <div>
                <a href={v.url} target="_blank" rel="noopener noreferrer">
                  {v.title}
                </a>
              </div>
            </div>
          ))}
        <div className="pt-3 pb-8">
          <div className="text-xs">
            <a
              href="https://zenn.dev/wheatandcat"
              target="_blank"
              rel="noopener noreferrer"
            >
              もっと読む
            </a>
          </div>
        </div>
      </div>
    </div>
  </div>
);

export default News;

■ (After)src/components/organisms/Top/News.astro

---
import type { FeedItem } from '@/lib/feed'
import dayjs from 'dayjs'

interface Props {
  feedItems: FeedItem[]
}

const { feedItems } = Astro.props
const hatenaItems = feedItems.filter((v) => v.type === 'hatena').slice(0, 3)
const zennItems = feedItems.filter((v) => v.type === 'zenn')
---
<div>
  <h1 class="text-2xl mb-7">Tech blog</h1>
  <div>
    <div class="my-2">
      <span class="bg-gray-300 py-1 px-2 text-xs font-bold">Hatena</span>
    </div>
    <div class="font-light">
      {hatenaItems.map((v) => (
        <div class="mb-5">
          <div>{dayjs(v.createdAt).format('YYYY-MM-DD')}</div>
          <a href={v.url} target="_blank" rel="noopener noreferrer">
            {v.title}
          </a>
        </div>
      ))}
      <div class="pt-3 pb-8">
        <div class="text-xs">
          <a href="https://www.wheatandcat.me/" target="_blank" rel="noopener noreferrer">
            もっと読む
          </a>
        </div>
      </div>
    </div>
    <div class="my-2">
      <span class="bg-gray-300 py-1 px-2 text-xs font-bold">Zenn</span>
    </div>
    <div class="font-light">
      {zennItems.map((v) => (
        <div class="mb-5">
          <div>{dayjs(v.createdAt).format('YYYY-MM-DD')}</div>
          <div>
            <a href={v.url} target="_blank" rel="noopener noreferrer">
              {v.title}
            </a>
          </div>
        </div>
      ))}
      <div class="pt-3 pb-8">
        <div class="text-xs">
          <a href="https://zenn.dev/wheatandcat" target="_blank" rel="noopener noreferrer">
            もっと読む
          </a>
        </div>
      </div>
    </div>
  </div>
</div>

React コンポーネントと比べると、Astro 側は Astro.props から受け取り、テンプレート構文もかなり素直。
静的サイト用途なら、むしろこちらのほうがシンプルで読みやすいと感じた。

Vercel → Cloudflare Pagesへの移行

Astro はビルドすると静的な HTML 一式になるため、Cloudflare Pages へのデプロイもかなり簡単。

www.cloudflare.com

Cloudflare で API Token を作成し、以下の GitHub Actions を用意すれば、main ブランチへの push で自動デプロイできる。

.github/workflows/deploy_website.yml

name: deploy website
on:
  workflow_dispatch:
    inputs:
      ref:
        description: branch|tag|SHA to checkout
        default: "main"
        required: true
  schedule:
    - cron: "0 2 * * *"
  push:
    branches:
      - main
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.ref || github.ref }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: unicorn-website
          directory: dist
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}
          branch: main

あとは移行先のサイトに既存ドメインを紐づければ完了。
以下の URL でアクセスできるようになり、移行は無事完了した。

www.unicornllc.dev

まとめ

  • 静的サイト用途なら、Astro × Cloudflare Pages はかなり扱いやすい
  • Next.js × Vercel は便利だが、静的サイトまで全部そこに載せると無料枠の圧迫が起きやすい
  • Astro は「静的であること」を前提にした設計が分かりやすく、初めて触ってもかなり面白かった
  • 今後も静的サイトはこの組み合わせを使っていこうと思った