Nuxt3でページベースルーティングへ移行
tax-assistantプロジェクトで、単一ページ + クエリパラメータベースのタブ管理から、Nuxt3のページベースルーティング + Pinia状態管理へ移行を行った。
背景:なぜ移行が必要だったのか
従来の実装では、全てのタブを単一ページ(index.vue)内で管理し、/?tab=receipt や /?tab=creditcard のようにクエリパラメータでタブを切り替えていた。
この方式には以下の問題があった。
問題1:履歴管理コードの複雑さ
クエリパラメータベースでは、ブラウザの「戻る/進む」機能(Alt+矢印)を動作させるために、明示的な実装が必要だった。
// 従来の useTabQuerySync.ts での履歴管理
function updateQueryParams(isTabChange = false) {
// タブ切替 → push(戻るボタンで前のタブに戻れる)
// フィルタ変更 → replace(履歴が大量に積まれるのを防ぐ)
if (isTabChange) {
router.push({ query })
} else {
router.replace({ query })
}
}
この「push と replace の使い分け」が常に正しく行われる必要があり、バグの温床になっていた。
問題2:初期ロード時の履歴上書き
遷移先のコンポーネントが初期化時に router.replace() を呼び出すと、ユーザーの遷移履歴を上書きしてしまう問題があった。
1. クレカ明細で「仕訳ルール編集」リンクをクリック
2. ブラウザが履歴に新しいエントリを追加
3. RuleListView.vue がマウント
4. onMounted → loadData() が実行
5. loadData() 内で selectRuleByIndex() を呼び出し
6. selectRuleByIndex() → updateQueryParams() → router.replace({ query })
7. replace により直前の履歴エントリが上書きされる
8. 結果:履歴に「クレカ明細」のエントリがなくなり、戻れない
Codexレビューで指摘された問題点
計画ドキュメント(page-based-routing-refactor-plan.md)をCodex CLI(GPT-5.2)にレビュー依頼した結果、以下の指摘を受けた。
高優先度
- 移行計画の矛盾: Phase 0の「全テストパスでPhase 1へ」と、履歴テスト5件失敗の修正はPhase 2という記述が矛盾
- 改善案: 履歴修正をPhase 1に前倒し、またはPhase 0完了条件を「既知不具合は追跡チケット化し除外」に変更
中優先度
- テスト計画の不足: キーボードショートカットのE2E具体テストが不足、上下矢印無効化の保証が未カバー
- クエリパラメータ異常系未定義: 欠落、不正値、組み合わせ不整合時のフォールバック規則が未定義
- URL設計の不整合: パラメータ名が混在(
docType/type、id/index) - 旧URLリダイレクト: 301を使うとブラウザキャッシュで固定化される恐れ → 302/307を推奨
低優先度
- Piniaユニットテストのフレーク要因: 外部依存(
loadBatches())を叩く可能性
これらの指摘を踏まえて計画を修正し、実装を進めた。
実装:Phase 1-3の詳細
Phase 1:準備(既存機能に影響なし)
Piniaストアの作成
5つのストアを新規作成した。
// stores/navigation.ts - ナビゲーション状態
export const useNavigationStore = defineStore('navigation', () => {
const previousRoute = ref<string | null>(null)
const selectedYear = ref<string | null>(null)
const selectedMonth = ref<string | null>(null)
function $reset() {
previousRoute.value = null
selectedYear.value = null
selectedMonth.value = null
}
return { previousRoute, selectedYear, selectedMonth, $reset }
})
// stores/batches.ts - バッチ情報
export const useBatchesStore = defineStore('batches', () => {
const batches = ref<Batch[]>([])
const currentBatchId = ref<string | null>(null)
async function loadBatches() { /* ... */ }
function $reset() {
batches.value = []
currentBatchId.value = null
}
return { batches, currentBatchId, loadBatches, $reset }
})
// stores/receipts.ts - レシートデータ
// stores/client.ts - クライアント情報
// stores/ui.ts - UI状態(プレビュー幅など)
ストアリセット方針も定義した。
| イベント | リセットするストア | 保持するストア |
|---|---|---|
| クライアント切替 | batches, navigation, receipts | client, ui |
| ページ離脱(リロード) | 全て | - |
| タブ間遷移 | なし | 全て |
共通レイアウトの作成
layouts/new-default.vue として新しいレイアウトを作成。この時点では既存に適用しない。
<!-- layouts/new-default.vue -->
<template>
<div class="app">
<header class="header">
<h1>Tax Assistant</h1>
<span class="client-name">{{ clientName }}</span>
<nav class="tabs">
<NuxtLink to="/vouchers" class="tab">読取一覧</NuxtLink>
<NuxtLink to="/by-account" class="tab">科目別一覧</NuxtLink>
<NuxtLink to="/duplicate" class="tab">重複チェック</NuxtLink>
<!-- ... -->
</nav>
</header>
<main>
<slot />
</main>
</div>
</template>
旧URL互換ミドルウェアの作成(無効化状態)
// middleware/legacy-redirect.global.ts
export default defineNuxtRouteMiddleware((to) => {
// 1. 旧URLの検出とリダイレクト
if (to.path === '/' && to.query.tab) {
const tab = to.query.tab as string
const tabMigrations: Record<string, string> = {
'receipt': '/vouchers',
'miller': '/by-account',
'duplicate': '/duplicate',
'result': '/result',
'square': '/square',
'creditcard': '/creditcard',
'shiwake-rules': '/shiwake-rules',
'matrix': '/matrix',
'account-master': '/account-master',
'journal': '/journal',
'document-types': '/document-types',
}
const newPath = tabMigrations[tab]
if (newPath) {
const newQuery = migrateQueryParams(to.query)
return navigateTo({ path: newPath, query: newQuery }, { redirectCode: 302 })
}
// 未知のtab値の場合はデフォルトへ
return navigateTo('/vouchers', { redirectCode: 302 })
}
// 2. ルートパスのデフォルトリダイレクト
if (to.path === '/' && !to.query.tab) {
return navigateTo('/vouchers', { redirectCode: 302 })
}
})
Phase 2:ページ分離(1ページずつ)
全10ページを順次移行した。
pages/
├── index.vue # / → /vouchers へリダイレクト
├── vouchers.vue # /vouchers(読取一覧)
├── by-account.vue # /by-account(科目別一覧)
├── duplicate.vue # /duplicate(重複チェック)
├── result.vue # /result(結果)
├── square.vue # /square(Square明細)
├── creditcard.vue # /creditcard(クレカ明細)
├── shiwake-rules.vue # /shiwake-rules(勘定科目ビュー & ルール一覧ビュー)
├── journal.vue # /journal(仕訳)
├── matrix.vue # /matrix(月次推移表)
├── account-master.vue # /account-master(勘定科目マスター)
└── document-types.vue # /document-types(帳票設定)
移行順序は、最もシンプルな /matrix から開始し、最も複雑な /vouchers は中盤で移行した。
usePageQuerySync の導入
useTabQuerySync の後継として usePageQuerySync を作成した。
// composables/usePageQuerySync.ts
interface UsePageQuerySyncOptions {
buildQuery: () => Record<string, string | undefined>
onActivate?: () => void
}
export function usePageQuerySync(options: UsePageQuerySyncOptions) {
const { buildQuery, onActivate } = options
const router = useRouter()
const route = useRoute()
const isInitialized = ref(false)
function updateQueryParams(usePush = false) {
if (!import.meta.client || !isInitialized.value) return
// 空文字・undefinedをフィルタリング
const rawQuery = buildQuery()
const query: Record<string, string> = {}
for (const [key, value] of Object.entries(rawQuery)) {
if (value !== undefined && value !== '') {
query[key] = value
}
}
// 重複ナビゲーション防止
const currentQueryWithoutTab = /* ... */
if (isSameQuery) return
if (usePush) {
router.push({ query })
} else {
router.replace({ query })
}
}
function markInitialized() {
isInitialized.value = true
updateQueryParams(false) // 初回はreplace
onActivate?.()
}
return { isInitialized, updateQueryParams, markInitialized }
}
ポイント:
tab=xxxパラメータは不要(パス自体がタブを表す)currentTabの inject も不要- パラメータ名のプレフィックスも不要(
receiptYear→year)
Phase 3:クリーンアップ(未実施)
以下はまだ実施していない。
- 旧URL互換ミドルウェア有効化
- 旧
index.vueの削除 useTabQuerySyncの削除
核心的な気づき:push/replace問題はそもそも発生しない
移行中に最も重要な気づきを得た。
ページベースルーティングでは、push と replace の使い分け問題はそもそも発生しない。
従来の問題
クエリパラメータベースでは、タブ切り替えもフィルタ変更も同じページ内の状態変更だった。そのため「どちらを履歴に残すか」を明示的に判断する必要があった。
/?tab=receipt → /?tab=creditcard → push(戻れるべき)
/?tab=receipt&year=2024 → /?tab=receipt&year=2025 → replace(戻らなくていい)
この判断を全ての箇所で正しく行うのは困難で、バグの原因になっていた。
ページベースでの解決
ページベースルーティングでは、タブ切り替え = ページ遷移になる。
/vouchers → /creditcard → Vue Routerが自動でpush
/vouchers?year=2024 → /vouchers?year=2025 → replaceを使う(ページ内の状態変更)
タブ切り替え時の履歴管理は Vue Router が自動で行うため、開発者が push を明示的に呼ぶ必要がない。開発者が気にするのは「ページ内のフィルタ変更時に replace を使う」ことだけ。
結論:ブラウザ標準のAlt+矢印で十分
ページベースルーティングに移行すれば、Alt+矢印(ブラウザの戻る/進む)が自然に動作する。これまで実装していた履歴管理コードは 不要になるので削除できる。
| 削除対象 | 理由 |
|---|---|
composables/useTabQuerySync.ts | ページ遷移で履歴が自動管理されるため |
router.afterEach / watch(() => route.fullPath, ...) | 戻る/進むでページが切り替わるので状態同期不要 |
switchTab内のrouter.push | <NuxtLink>でのページ遷移に置き換え |
テスト結果
Phase 2 完了時点
| カテゴリ | パス | 失敗 | 備考 |
|---|---|---|---|
| 全体 | 49 | 8 | 失敗は旧URLパターン期待のテスト |
失敗したテストは旧URL形式(/?tab=xxx)を期待しているもの。Phase 3でレガシーリダイレクトを有効化すれば解決予定。
テストコードの移行方針
機能テストは新形式(/xxx)に統一し、レガシーリダイレクトテストは別ファイルで管理する方針とした。
// 新形式のテスト
test('読取一覧から科目別一覧へ遷移後、戻る/進むが動作する', async ({ page }) => {
await page.goto('/vouchers')
await page.click('[data-testid="tab-by-account"]')
await expect(page).toHaveURL(/\/by-account/)
// 戻る
await page.goBack()
await expect(page).toHaveURL(/\/vouchers/)
// 進む
await page.goForward()
await expect(page).toHaveURL(/\/by-account/)
})
// レガシーリダイレクトテスト(別ファイル)
test('旧URL /?tab=receipt は /vouchers にリダイレクトされる', async ({ page }) => {
await page.goto('/?tab=receipt')
await expect(page).toHaveURL(/\/vouchers/)
})
学び
1. ブラウザ標準機能に任せる
複雑な履歴管理ロジックを自前で書くより、ブラウザ/フレームワークの標準機能に任せた方がシンプルで堅牢。
2. SPAの罠
SPAで全てをクエリパラメータで管理すると、結果的にMPAより複雑になることがある。Nuxtのようなフレームワークではページベースルーティングを素直に使うべき。
3. Codexレビューは計画段階で有効
実装前に計画ドキュメントをAIにレビューさせることで、矛盾点や不足を早期に発見できた。
関連ファイル
memo/2026-01-26/page-based-routing-refactor-plan.md- 計画ドキュメントmemo/2026-01-26/browser-history-bug-investigation.md- 履歴バグ調査frontend/app/composables/usePageQuerySync.ts- 新しいcomposablefrontend/app/composables/useTabQuerySync.ts- 削除予定(Phase 3)