#migration (3)
概要
Next.js App Router + Firebase → Astro + CF Pages への移行時のフォーム実装パターン。reCAPTCHA v3、Firebase Cloud Functions、HubSpot 埋め込みをどう置き換えるか。
アーキテクチャ変更
| 要素 | Next.js | Astro |
|---|---|---|
| CAPTCHA | reCAPTCHA v3 | Cloudflare Turnstile |
| バックエンド | Firebase Cloud Functions | CF Pages Functions (/api/*) |
| フォーム送信 | Server Action + Firebase | CF API ルート |
| HubSpot | 埋め込み(SSR時にスキップ) | React Island化 |
実装フロー
1. Turnstile の統合
<!-- Astro コンポーネント -->
<div id="turnstile-container"></div>
<script>
import Turnstile from '@/components/Turnstile.tsx';
</script>
<Turnstile />
2. API ルートの実装
// src/pages/api/submit-form.ts
export async function POST({ request }) {
const formData = await request.json();
// Turnstile 検証
const verifyResponse = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: JSON.stringify({
secret: import.meta.env.TURNSTILE_SECRET_KEY,
response: formData.captchaToken
})
});
if (!verifyResponse.ok) {
return new Response(JSON.stringify({ error: 'Captcha failed' }), { status: 400 });
}
// メール送信等の処理
return new Response(JSON.stringify({ success: true }));
}
3. HubSpot 埋め込みの React Island化
// React コンポーネント
export default function HubSpotForm() {
useEffect(() => {
if (window.hbspt) {
window.hbspt.forms.create({
region: 'na1',
portalId: '...',
formId: '...',
target: '#hubspot-form'
});
}
}, []);
return <div id="hubspot-form" />;
}
Astro での使用:
<HubSpotForm client:load />
メリット
- CF Pages はサーバーレス、メンテナンス不要
- Turnstile は無料+プライバシー優先
- Astro + React Island で必要な部分のみインタラクティブ
検討点
- Firebase から CF Functions への認証情報移行
- メール送信ロジックの Durable Objects 利用の検討
- ログ保存先の決定(D1 / Firestore など)
現状技術スタック
フロントエンド: Next.js 14.2.35 (App Router) + React 18.3.1 + TypeScript 5.x スタイリング: Tailwind CSS v3 + SCSS Module(microCMSリッチテキスト用) 状態管理: Zustand(2ストア) CMS: microCMS BaaS: Firebase(Firestore, Functions, Auth) デプロイ: Google App Engine
ページ構成: 約50ページ(LeanGoコーポレート、dejamサービス、ad-future)、動的ルート多数
移行のネック項目
| 項目 | 影響度 | 課題 | 解決策 |
|---|---|---|---|
| ISR (revalidateTag) | 高 | Next.js固有、microCMS Webhook対応が必要 | Astro APIルートでWebhook受信→オンデマンドリビルド |
| breakpoint検出 | 高 | useLayoutEffect でwindowサイズ判定、全体ローディング | CSSメディアクエリで代替、JS削減可能 |
| Firestoreクライアント | 中 | /dejam/website/ のみ | client:load Island に分離 |
| Firebase Functions | 中 | httpsCallable(‘sendMail’) | Astro APIエンドポイント or SSRで代替 |
| HubSpotフォーム | 中 | クライアント専用インタラクション | Astro Componentで Island化 |
Astro採用の優位性
Astro Islands パターン:
- ページ全体をJSで制御せず、インタラクティブ必須部分のみを「島」として独立
- ページの大部分は静的HTMLで高速配信、フォーム/カルーセル等のみJS送信
Next.jsと比較した利点:
- ファイル形式がHTML的 → デザイナーが読める
- 概念が少ない → HTML + Tailwind で完結、React hooks/Server-Client判断不要
- Claude Code との相性 → 生成コードがシンプル、副作用少、スコープ局所
- 役割分離が自然 → 静的部分(デザイナー) vs Islands/API(エンジニア)
- サイト特性に合致 → 90%以上が静的 → ゼロJS配信で最高速
データフェッチング戦略
- microCMS: Content Collections + ISR相当(オンデマンドビルド)
- JSON: readFileSync → Astro loaders に置換
- Firestore: Client Islands のみ(useEffect内で直接呼び出し)
- Firebase Functions: Astro SSR APIエンドポイントに移行
デザイナー・エンジニア作業領域分離
| 領域 | 対象 | 担当 |
|---|---|---|
| 静的マークアップ + Tailwind | ほぼ全ページ | デザイナー(.astro) |
| Islands(React/Svelte) | フォーム、カルーセル、Firestore | エンジニア |
| API, デプロイ, CMS連携 | 機能実装 | エンジニア |
リスク・注意点
- テスト未整備 → 移行時のリグレッション検知は手動確認に依存
- GAEデプロイ対応 → Node adapter 検証が必須
- breakpoint検出の移行 → CSSメディアクエリ設計の見直し
結論
大半が静的生成可能で、クライアントJS が限定的。Astro Islands は本件の要件と完全マッチ。ビルドキャッシング戦略の設計が鍵。
概要
Phase 5.8で React 19 を完全脱却し、19コンポーネントを Astro + vanilla JS / Svelte 5 に移植。削除パッケージ数:10+、バンドルサイズ削減:30% 以上。
変換パターン別の実装解説
パターン1: React の状態管理 → data-* 属性 + vanilla JS
React の useState + useEffect を以下のように置き換え:
Before(React):
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
}, [isOpen]);
After(Astro + vanilla JS):
<button id="sp-menu-toggle" aria-expanded="false">...</button>
<nav id="sp-menu-overlay" class="hidden" />
<script>
const toggle = document.getElementById('sp-menu-toggle');
const overlay = document.getElementById('sp-menu-overlay');
let isOpen = false;
toggle.addEventListener('click', () => {
isOpen = !isOpen;
if (isOpen) {
overlay.classList.remove('hidden');
overlay.classList.add('flex');
document.body.style.overflow = 'hidden';
} else {
overlay.classList.add('hidden');
document.body.style.overflow = '';
}
});
</script>
Key points:
- Props を
data-*属性で受け渡し(const formId = container.dataset.formId) - クロージャで状態管理(モジュールスコープの
let変数) document.addEventListener('astro:after-swap', initFunction)で View Transitions 対応
パターン2: React hooks → vanilla JS(body scroll lock, IntersectionObserver)
useBodyScrollLock の置き換え:
function lockScroll(): void {
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
function unlockScroll(): void {
document.documentElement.style.overflowY = '';
document.body.style.overflowY = '';
document.body.style.position = '';
document.body.style.width = '';
}
IntersectionObserver の直接使用:
// 追従CTA: FV要素が画面外に出たら表示
const container = document.getElementById('dejam-following-cta');
const target = document.getElementById('first-view');
window.addEventListener('scroll', () => {
const { bottom } = target.getBoundingClientRect();
if (bottom < 0) {
container.classList.remove('hidden');
container.classList.add('flex');
} else {
container.classList.add('hidden');
container.classList.remove('flex');
}
}, { passive: true });
パターン3: embla-carousel-react → vanilla embla-carousel
useEmblaCarousel の廃止:
<div class="carousel">
<div class="carousel__viewport">
<ul class="carousel__container">
{imgs.map((src) => <li><img src={src} /></li>)}
</ul>
</div>
<ul class="carousel__dots">
{imgs.map((_, i) => <li class="dot" data-index={i} />)}
</ul>
</div>
<script>
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
function initCarousel(root) {
const viewport = root.querySelector('.carousel__viewport');
const embla = EmblaCarousel(viewport, { loop: true }, [Autoplay()]);
const dots = [...root.querySelectorAll('.dot')];
function updateDots() {
const selected = embla.selectedScrollSnap();
dots.forEach((dot, i) => {
dot.classList.toggle('bg-primary', i === selected);
});
}
embla.on('select', updateDots);
dots.forEach((dot) => {
dot.addEventListener('click', () => {
const index = Number(dot.dataset.index);
embla.scrollTo(index);
});
});
}
document.querySelectorAll('.carousel').forEach(initCarousel);
</script>
Key points:
EmblaCarousel(viewport, options, plugins)で直接初期化embla.on('select', callback)でイベントリスナー登録embla.scrollTo(index)/scrollPrev()/scrollNext()で操作
パターン4: 複雑な UI 状態管理(PC/SP ヘッダー)
Zustand store → クロージャ変数:
function initDejamHeaderPC() {
const wrapper = document.querySelector('[data-dejam-header-pc]');
const dropdownPanels = wrapper.querySelectorAll('[data-dropdown-panel]');
const navTriggers = wrapper.querySelectorAll('[data-nav-trigger]');
let activeDropdownId = ''; // ← クロージャで状態管理
function showDropdown(id) {
activeDropdownId = id;
dropdownPanels.forEach((panel) => {
if (panel.dataset.dropdownPanel === id) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
updateNavStyles();
}
function closeDropdown() {
activeDropdownId = '';
dropdownPanels.forEach((p) => p.classList.add('hidden'));
updateNavStyles();
}
// mouseenter / mouseleave リスナー
navTriggers.forEach((trigger) => {
trigger.addEventListener('mouseenter', () => {
showDropdown(trigger.dataset.navTrigger);
});
});
wrapper.addEventListener('mouseleave', closeDropdown);
}
initDejamHeaderPC();
document.addEventListener('astro:after-swap', initDejamHeaderPC);
SP メニューのネストされたアコーディオン:
// 各ドロップダウントリガーをループで登録
dropdownTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const dropdownId = trigger.dataset.spDropdownTrigger;
const content = root.querySelector(`[data-sp-dropdown-content="${dropdownId}"]`);
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
content.classList.add('hidden');
trigger.setAttribute('aria-expanded', 'false');
icon.src = '/images/icon/icon_plus_primary.svg';
} else {
content.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
icon.src = '/images/icon/icon_minus_primary.svg';
}
});
});
パターン5: react-hook-form → Svelte 5 Runes
React フォーム複雑化時は Svelte 5 を採用。理由:Runes による洗練された状態管理。
Svelte 5 の $state / $derived:
<script>
import { z } from 'zod';
// Props
const { turnstileSiteKey } = $props();
// State
let companyName = $state('');
let email = $state('');
let fieldErrors = $state({});
let isSubmitting = $state(false);
// Derived(自動再計算)
const formData = $derived({
companyName,
email,
// ...
});
const isButtonDisabled = $derived(
isSubmitting || !turnstileToken || !termCheckbox
);
// Validation(immutable pattern)
function validateField(fieldId) {
const result = contactSchema.safeParse(formData);
if (result.success) {
const { [fieldId]: _, ...rest } = fieldErrors;
fieldErrors = rest; // 新オブジェクト生成
return;
}
fieldErrors = { ...fieldErrors, [fieldId]: issue.message };
}
// Event handlers
function handleInput(fieldId, event) {
const target = event.target;
switch (fieldId) {
case 'companyName': companyName = target.value; break;
case 'email': email = target.value; break;
}
if (touched[fieldId]) validateField(fieldId);
}
async function handleSubmit(event) {
event.preventDefault();
if (!validateAllFields()) return;
isSubmitting = true;
try {
await sendContact({ companyName, email }, turnstileToken);
window.location.href = '/complete/';
} finally {
isSubmitting = false;
}
}
</script>
<form onsubmit={handleSubmit}>
{#each FORM_FIELDS as field}
<input
value={companyName}
oninput={(e) => handleInput('companyName', e)}
onblur={() => handleBlur('companyName')}
/>
{#if fieldErrors.companyName}
<p>{fieldErrors.companyName}</p>
{/if}
{/each}
<button disabled={isButtonDisabled} type="submit">送信</button>
</form>
Why Svelte for forms:
$state+$derivedで React のuseState+useMemoを1行で表現oninput/onchangeで直接バインディング(event delegation 不要)- Zod スキーマとの統合が簡潔
共通の工夫ポイント
1. astro:after-swap 対応(View Transitions)
function init() { /* 初期化処理 */ }
init();
document.addEventListener('astro:after-swap', init);
ClientRouter でページ遷移時も再初期化が必要。
2. esbuild パースエラー回避
フロントマター内で HTML タグを使わない:
---
// これは NG(dev 時 esbuild がパースエラー)
/**
* <script>alert('xss')</script>
*/
// これは OK(// コメント使用)
// - ドロップダウン表示
// - hover イベント管理
---
3. data-* 属性で Props 受け渡し
<div data-carousel data-autoplay="true" data-loop="false">
...
</div>
<script>
const carousel = document.querySelector('[data-carousel]');
const autoplay = carousel.dataset.autoplay === 'true';
const loop = carousel.dataset.loop === 'true';
</script>
4. client: ディレクティブ不要
vanilla JS は自動的にクライアント実行。<script is:inline> で SSR をスキップ可能。
削除した npm パッケージ
react 19
react-dom 19
@react-aria/*(17 dependency)
embla-carousel-react → embla-carousel に統一
zustand(2つの store のみ利用、残すのは検討中)
バンドルサイズ削減:
- Before: ~180KB (gzipped)
- After: ~125KB (gzipped)
- 削減: 30%
実装チェックリスト
各コンポーネント移植時:
- data-* 属性で Props 受け渡し
-
astro:after-swapイベントリスナー登録 - TypeScript 型定義(
HTMLElementなど) - null guard で TS narrowing(クロージャ内で確実に)
-
classListで class 切り替え(immutable pattern) - aria-* 属性で a11y 対応
-
{ passive: true }で scroll リスナー最適化
まとめ
| React | vanilla JS / Svelte |
|---|---|
useState | クロージャ変数 / $state |
useEffect | addEventListener / onMount |
useMemo | $derived |
| Props | data-* 属性 + dataset |
| Store(Zustand) | クロージャ変数 |
| Hook(embla) | 直接 SDK 呼び出し |
| Event delegation | 直接 addEventListener |
結論: React を脱却することで、ビルド時間短縮・バンドルサイズ削減・保守性向上を同時実現。Astro + vanilla JS は小規模な UI インタラクションに最適。複雑な state 管理が必要な場合のみ Svelte 5 を検討。