はじめに
金融系プロジェクトで、Nuxt.js(Vue 2ベース)で構築されたフロントエンドを Next.js(React)にリプレイスする機会がありました。チーム規模は30名、既存のコードベースは数百コンポーネント。段階的な移行戦略が求められる中で得た知見を共有します。
なぜ移行したのか
移行の判断に至った主な理由は3つありました。
- Vue 2のEOL対応 — Nuxt 2はVue 2に依存しており、セキュリティアップデートの継続が不透明だった
- React エコシステムの優位性 — チーム内にReact経験者が多く、採用面でもReactの方が有利だった
- TypeScript との親和性 — Next.js + TypeScript の型推論がNuxt 2と比較して格段に強力だった
移行戦略: Strangler Fig パターン
一括リプレイスではなく、Strangler Fig パターンを採用しました。
[Reverse Proxy (nginx)]
├── /new/* → Next.js アプリ
└── /* → Nuxt.js アプリ(既存)
新規ページは最初からNext.jsで作り、既存ページは優先度順に段階的に移行。これにより、リスクを最小化しつつ継続的にデリバリーできました。
Web Components によるパーツ単位の段階移行
ページ単位の移行だけでなく、パーツ単位でも段階的に進めました。ここで活用したのが Web Components です。
Next.js(React)で作ったコンポーネントを Custom Elements としてラップし、既存の Nuxt.js ページ内にそのまま埋め込む。これにより、ページ全体を書き換えなくても、ヘッダーやサイドバーなどの共通パーツから先行してReact化できました。
// React コンポーネントを Custom Element としてラップ
import { createRoot } from "react-dom/client";
class ReactHeader extends HTMLElement {
connectedCallback() {
const root = createRoot(this);
root.render(<Header />);
}
}
customElements.define("react-header", ReactHeader);
<!-- Nuxt.js テンプレート内でそのまま使える -->
<template>
<div>
<react-header></react-header>
<main>{{ content }}</main>
</div>
</template>
この方法のメリットは、フレームワークの境界を越えてコンポーネントを共有できることです。移行期間中に「Nuxt版とNext版で同じUIを二重メンテする」という事態を避けられました。
ルーティングの違い
Nuxt.js と Next.js のファイルベースルーティングは似ているようで、細かい違いがあります。
| 機能 | Nuxt.js | Next.js (Pages Router) |
|---|---|---|
| 動的ルート | _id.vue | [id].tsx |
| レイアウト | layouts/default.vue | _app.tsx + 共通コンポーネント |
| ミドルウェア | middleware/auth.js | middleware.ts (root) |
| データ取得 | asyncData / fetch | getServerSideProps / getStaticProps |
状態管理の移行
Vuex から React の状態管理への移行が最も工数がかかりました。移行先として採用したのは jotai です。
Vuex の mutation / action ベースのアーキテクチャは冗長になりがちですが、jotai の atom ベースのアプローチはシンプルで TypeScript との相性も抜群でした。
// Vuex (Before)
export const state = () => ({
user: null,
isAuthenticated: false,
});
export const mutations = {
SET_USER(state, user) {
state.user = user;
state.isAuthenticated = !!user;
},
};
// jotai (After)
import { atom } from "jotai";
const userAtom = atom<User | null>(null);
const isAuthenticatedAtom = atom((get) => get(userAtom) !== null);
Vuex では state + mutations + actions + getters を定義する必要がありましたが、jotai では atom と derived atom だけで完結します。ボイラープレートが大幅に削減され、コードの見通しが良くなりました。
また、Vuex のグローバルステートに載せていたサーバーデータ(APIレスポンスのキャッシュ等)は TanStack Query に移行しました。
import { useQuery } from "@tanstack/react-query";
function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
});
}
TanStack Query がキャッシュ・再取得・ローディング状態を一元管理してくれるため、Vuex に書いていたデータ取得ロジックや isLoading フラグの管理が不要になりました。結果として、jotai にはクライアント固有のUI状態(モーダルの開閉、フォーム入力値など)だけが残り、状態管理がすっきりと整理されました。
認証周りの移行
Firebase Auth を使った認証フローの移行も重要なポイントでした。
Nuxt.js では @nuxtjs/auth モジュール経由で Firebase Auth を利用していましたが、Next.js では firebase/auth を直接利用する形に変更。認証状態の管理は onAuthStateChanged をベースにしたカスタムフックに集約しました。
// hooks/useAuth.ts
import { onAuthStateChanged, User } from "firebase/auth";
import { useEffect } from "react";
import { atom, useAtom } from "jotai";
import { auth } from "@/lib/firebase";
const userAtom = atom<User | null>(null);
const loadingAtom = atom(true);
export function useAuth() {
const [user, setUser] = useAtom(userAtom);
const [loading, setLoading] = useAtom(loadingAtom);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return unsubscribe;
}, []);
return { user, loading };
}
ルート保護は middleware.ts でCookieに保存したトークンを検証する形にしました。
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("firebase-auth-token")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
ハマりポイントと対策
1. CSS Modules の className の違い
Vue の scoped style から CSS Modules への移行で、クラス名の命名規則が変わりバグが頻発しました。
対策: ESLint の css-modules/no-unused-class ルールを導入し、未使用・未定義のクラス参照を検出。
2. SSR/SSG の挙動差
Nuxt.js の asyncData は SSR 時に自動的にシリアライズされますが、Next.js(Pages Router)の getServerSideProps / getStaticProps ではデータの受け渡し方が異なります。特に Date オブジェクトなど、JSON シリアライズできない値の扱いでハマりがちです。
対策: 最初にデータフローの設計ドキュメントを作成し、チーム全体で認識を合わせた上で移行を進めました。
3. テストの書き直し
Vue Test Utils から React Testing Library への移行は、テストの書き方の思想自体が異なるため、単純な置換では対応できませんでした。
対策: 「実装の詳細ではなくユーザーの振る舞いをテストする」という RTL の原則に沿って、テストを一から設計し直しました。結果的にテストの品質は向上しました。
移行の結果
約6ヶ月かけて段階的に移行を完了し、以下の成果が得られました。
- ビルド時間: 約40%短縮(Nuxt 2のwebpack → Next.jsのSWC)
- バンドルサイズ: TanStack Query + jotai への移行で状態管理コードが約30%削減
- 開発者体験: TypeScript の型推論改善により、型エラーの早期検出が可能に
- 採用: React エンジニアの応募数が増加
まとめ
フレームワークの移行は技術的な作業だけでなく、チームの合意形成やリスク管理が重要です。Strangler Fig パターンによる段階的な移行は、大規模プロジェクトでは特に有効でした。
同じような移行を検討している方の参考になれば幸いです。