技術 約4分で読めます

React2ShellがめんどくさすぎてNext.jsからAstroに移行した

経緯

仕事で管理しているコーポレートサイトの話。

12月、React2Shellが発覚した。CVSS 10.0。サーバー乗っ取り可能。

すぐにパッチを当てた。19.0.1にアップデート。

数日後、「初回修正が不完全でした」とDoS脆弱性が追加で公開された。19.0.2にアップデート。

さらに数日後、「19.0.2もまだ脆弱でした」。19.0.3にアップデート。

もういいわ。

そもそもReactいる?

冷静に考えた。問題のサイトは:

  • 5ページの静的サイト
  • お問い合わせフォームが1つ
  • ダークモード切り替えがある
  • OG画像を動的生成している

これにReact 19のServer Componentsが必要か? いらない。

さらに依存関係を見直したら、shadcn UIを60個インストールして、実際に使っているのはAlertDialogだけだった。

過剰設計の極み。

移行先の選定

候補理由
Astro他のサイトで使ってる、静的サイトに強い、Reactなしで動く
11tyシンプルだけど学習コストが…
HugoGo製、速いけど今回はパス

Astroに決定。理由は単純で、このブログが元からAstroで動いていて慣れているから。仕事のサイトもAstroに統一すれば管理が楽になる。

移行方針

既存プロジェクトを書き換えるのではなく、新規プロジェクトを作って段階的に移植する方針にした。

pnpm create astro@latest corporate-site-astro

理由:

  • 依存関係の競合を避けられる
  • 既存サイトは移行完了まで稼働できる
  • クリーンな状態で始められる

技術スタック比較

項目BeforeAfter
フレームワークNext.js 16Astro 5
Reactランタイムありなし
バンドルサイズ最小限
アイコンlucide-react純粋SVG
ダークモードnext-themesVanilla JS
フォームReact Hook FormVanilla JS
UIライブラリshadcn/radix (60個)なし

Reactを完全に排除した。

具体的な変換

ナビゲーション

Before (Next.js)

'use client';
import { usePathname } from 'next/navigation';

export function Navigation() {
  const pathname = usePathname();
  const isActive = (href: string) => pathname === href;
  // ...
}

After (Astro)

---
const pathname = Astro.url.pathname;
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
---

usePathnameフックが不要になった。Astroではビルド時にパスが確定するので、クライアントサイドのJavaScriptは一切いらない。

ダークモード

Before (next-themes)

import { ThemeProvider } from 'next-themes';

// layout.tsx
<ThemeProvider attribute="class" defaultTheme="system">
  {children}
</ThemeProvider>

After (Vanilla JS)

<script is:inline>
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  document.documentElement.classList.toggle('dark', theme === 'dark');
</script>

is:inlineでAstroのバンドルから除外し、HTMLに直接埋め込む。これでフラッシュ(一瞬白くなる現象)を防げる。

フォーム

Before (React Hook Form)

const { register, handleSubmit, formState } = useForm();

const onSubmit = async (data) => {
  // reCAPTCHA検証
  // fetch送信
  // 状態更新
};

After (Vanilla JS)

const form = document.getElementById('contact-form');
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const formData = new FormData(form);
  // reCAPTCHA検証
  // fetch送信
  // DOM操作で結果表示
});

React Hook Formの抽象化は便利だけど、フォームが1つしかないなら素のJavaScriptで十分。

AlertDialog

shadcn UIのAlertDialogを使っていたが、実装を見たら単なるモーダルだった。

<dialog id="alert-dialog">
  <p id="alert-message"></p>
  <button onclick="this.closest('dialog').close()">閉じる</button>
</dialog>

<script>
function showAlert(message) {
  document.getElementById('alert-message').textContent = message;
  document.getElementById('alert-dialog').showModal();
}
</script>

HTMLの<dialog>要素で十分だった。

OG画像生成

Next.jsのnext/og(Vercel製Satori)は便利だったが、静的サイトなら事前生成で問題ない。

選択肢:

  1. Figmaで作ってpublic/og.pngに置く
  2. sharpでビルド時に生成するスクリプトを書く

今回は1を選んだ。ページが5枚しかないので。

移行結果

ビルド出力の違い

Next.js (SSG)

out/
├── _next/
│   ├── static/
│   │   ├── chunks/
│   │   │   ├── framework-*.js      # React本体
│   │   │   ├── main-*.js           # Next.jsランタイム
│   │   │   ├── pages/_app-*.js
│   │   │   ├── pages/_error-*.js
│   │   │   └── webpack-*.js
│   │   └── css/
│   └── data/                        # プリレンダリングデータ
├── index.html
├── about.html
├── 404.html
├── _buildManifest.js
├── _ssgManifest.js
└── ...

Astro

dist/
├── index.html
├── about/index.html
├── contact/index.html
├── services/index.html
├── _astro/
│   └── *.css                        # CSSのみ
└── images/

Next.jsは静的出力でも _next/static/chunks/ にReactランタイムやwebpackのチャンクが入る。5ページのサイトなのにJSファイルが20個以上生成されていた。

Astroは JavaScriptファイルがゼロ。今回はクライアントサイドJSを一切使わない構成にしたので、_astro/ にはCSSしか入っていない。

ファイル構成

corporate-site-astro/
├── src/
│   ├── layouts/Layout.astro
│   ├── components/
│   │   ├── Navigation.astro
│   │   └── Footer.astro
│   ├── pages/
│   │   ├── index.astro
│   │   ├── about.astro
│   │   ├── contact.astro
│   │   └── services/
│   │       ├── index.astro
│   │       └── web.astro
│   └── styles/globals.css
└── public/
    ├── send_mail.php
    └── images/

依存関係

Before

{
  "dependencies": {
    "next": "^16.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@radix-ui/react-alert-dialog": "...",
    "@radix-ui/react-dialog": "...",
    // ...shadcn UIのコンポーネント60個
    "lucide-react": "...",
    "next-themes": "...",
    "react-hook-form": "..."
  }
}

After

{
  "dependencies": {
    "astro": "^5.0.0",
    "@astrojs/tailwind": "...",
    "tailwindcss": "^4.0.0"
  }
}

node_modulesのサイズが体感で1/3くらいになった。

まとめ

React2Shellの対応をきっかけに、そもそもReactが必要かを見直した結果:

  • 5ページの静的サイトにReact 19は過剰だった
  • shadcn UI 60個入れて1個しか使ってなかった
  • Vanilla JSで十分だった

脆弱性対応のたびにアップデートして祈るより、そもそも攻撃対象を減らす方が健全。

Astroは「必要なときだけReactを使える」ので、将来インタラクティブな機能が必要になったら部分的に導入すればいい。今はいらない。