🔄

はじめに

金融系プロジェクトで、Nuxt.js(Vue 2ベース)で構築されたフロントエンドを Next.js(React)にリプレイスする機会がありました。チーム規模は30名、既存のコードベースは数百コンポーネント。段階的な移行戦略が求められる中で得た知見を共有します。

なぜ移行したのか

移行の判断に至った主な理由は3つありました。

  1. Vue 2のEOL対応 — Nuxt 2はVue 2に依存しており、セキュリティアップデートの継続が不透明だった
  2. React エコシステムの優位性 — チーム内にReact経験者が多く、採用面でもReactの方が有利だった
  3. 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.jsNext.js (Pages Router)
動的ルート_id.vue[id].tsx
レイアウトlayouts/default.vue_app.tsx + 共通コンポーネント
ミドルウェアmiddleware/auth.jsmiddleware.ts (root)
データ取得asyncData / fetchgetServerSideProps / 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 パターンによる段階的な移行は、大規模プロジェクトでは特に有効でした。

同じような移行を検討している方の参考になれば幸いです。

WRITTEN BY nidoneko

Full-stack engineer with 8+ years of experience in TypeScript, React, Node.js, and cloud-native development across healthcare, finance, HR, and IoT domains.

View Profile →