- Remove legacyBehavior prop and nested <a> tags from Link components
- Modernize to Next.js 13+ Link API with className directly on Link
- Convert external links to plain <a> tags (LinkItem, Discord link)
- Remove unnecessary passHref props from Header components
- Fix tab switching by mapping string values to GridType enum
The tab switching issue was caused by trying to parse string values
("characters", "weapons", "summons") as integers when they needed to
be mapped to GridType enum values.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
PRD: Migrate i18n from next-i18next to next-intl for App Router
Current Status (Sep 2, 2025)
Migration is ~90% complete. App is now running successfully!
What's Working:
- ✅ Core configuration complete (next.config.js wrapped with plugin, middleware composed)
- ✅ App Router structure migrated to [locale] segments
- ✅ ALL 76 component files now have correct
next-intlimports - ✅ Navigation helper created at
/i18n/navigation.ts - ✅ All
react-i18nextandnext-i18nextimports have been replaced - ✅ App compiles and runs without import errors
- ✅ English locale loads successfully
What's Pending:
- ⚠️ 18 components have
<Trans>components that need refactoring tot.rich() - ⚠️ Most components still use standard Next.js navigation instead of locale-aware navigation
- ⚠️ Minor issue: Missing translation key
common.toasts.update.description. - ⚠️ Japanese locale testing needed
Next Steps:
- Test the app to verify it runs without import errors
- Update navigation imports to use locale-aware routing
- Refactor Trans components to use
t.rich() - Test locale switching
- Clean up old config files
Problem Statement
The application is displaying raw translation keys (e.g., party.segmented_control.characters) instead of localized strings. This is because:
- The codebase uses
next-i18nextwhich is designed for Pages Router - App Router pages don't have proper i18n provider setup
- Client components are trying to use
useTranslationfromnext-i18nextwithout a provider
Solution: Migrate to next-intl
We will migrate from next-i18next to next-intl, adopting idiomatic App Router patterns (Next 13–15): localized route segment, composed middleware (i18n + auth), and locale-aware navigation. We will reuse our existing JSON translation files and remove legacy Next.js i18n config to avoid conflicts.
Implementation Plan
1. Install Dependencies
npm install next-intl
2. Create i18n Configuration
Create /i18n.config.ts (single source of truth for locales):
export const locales = ['en', 'ja'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'en'
3. Compose Middleware (i18n + auth)
Compose next-intl middleware with our existing auth checks. Keep i18n first, then apply auth to the path without the locale prefix.
// middleware.ts
import createMiddleware from 'next-intl/middleware'
import {locales, defaultLocale, type Locale} from './i18n.config'
import {NextResponse} from 'next/server'
import type {NextRequest} from 'next/server'
const intl = createMiddleware({
locales,
defaultLocale,
localePrefix: 'as-needed' // Show locale in URL when not default
})
const PROTECTED_PATHS = ['/saved', '/profile'] as const
const MIXED_AUTH_PATHS = ['/api/parties', '/p/'] as const
export default function middleware(request: NextRequest) {
// Run next-intl first (handles locale detection, redirects, etc.)
const intlResponse = intl(request)
if (intlResponse) return intlResponse
const {pathname} = request.nextUrl
const seg = pathname.split('/')[1]
const pathWithoutLocale = locales.includes(seg as Locale)
? pathname.slice(seg.length + 1) || '/'
: pathname
const isProtectedPath = PROTECTED_PATHS.some(
(p) => pathWithoutLocale === p || pathWithoutLocale.startsWith(p + '/')
)
const isMixedAuthPath = MIXED_AUTH_PATHS.some(
(p) => pathWithoutLocale === p || pathWithoutLocale.startsWith(p)
)
const needsAuth =
isProtectedPath || (isMixedAuthPath && ['POST', 'PUT', 'DELETE'].includes(request.method))
if (!needsAuth) return NextResponse.next()
const accountCookie = request.cookies.get('account')
if (!accountCookie?.value) {
if (pathWithoutLocale.startsWith('/api/')) {
return NextResponse.json({error: 'Authentication required'}, {status: 401})
}
// Preserve locale in redirect
const url = request.nextUrl.clone()
url.pathname = '/teams'
return NextResponse.redirect(url)
}
try {
const account = JSON.parse(accountCookie.value)
if (!account.token) {
if (pathWithoutLocale.startsWith('/api/')) {
return NextResponse.json({error: 'Authentication required'}, {status: 401})
}
const url = request.nextUrl.clone()
url.pathname = '/teams'
return NextResponse.redirect(url)
}
} catch {
if (pathWithoutLocale.startsWith('/api/')) {
return NextResponse.json({error: 'Authentication required'}, {status: 401})
}
const url = request.nextUrl.clone()
url.pathname = '/teams'
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|_vercel|.*\\..*).*)']
}
4. Localized Layout (App Router)
Create /app/[locale]/layout.tsx and place NextIntlClientProvider here. Use unstable_setRequestLocale to tell Next the active locale and pre-generate locale params if statically building.
// app/[locale]/layout.tsx
import {NextIntlClientProvider} from 'next-intl'
import {getMessages, unstable_setRequestLocale} from 'next-intl/server'
import {locales} from '../../i18n.config'
export function generateStaticParams() {
return locales.map((locale) => ({locale}))
}
export default async function LocaleLayout({
children,
params: {locale}
}: {
children: React.ReactNode
params: {locale: string}
}) {
unstable_setRequestLocale(locale)
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
</body>
</html>
)
}
Notes:
- If
/app/layout.tsxexists, keep it minimal (global styles/fonts only) and avoid setting thelangattribute or an i18n provider there. The locale-specific layout above should own<html>/<body>.
5. Locale-Aware Navigation
Replace next/link and next/navigation with next-intl/navigation to preserve locale on navigation.
// Before
import Link from 'next/link'
import {useRouter, usePathname, useSearchParams} from 'next/navigation'
// After
import {Link, useRouter, usePathname, useSearchParams} from 'next-intl/navigation'
6. Update Component Imports
Change all translation imports from:
import { useTranslation } from 'next-i18next'
import { Trans } from 'next-i18next'
To:
import { useTranslations } from 'next-intl'
// Note: Trans component usage will need to be refactored
7. Update Translation Hook Usage
Change from:
const { t } = useTranslation('common')
// Usage: t('party.segmented_control.characters')
To:
const t = useTranslations('common')
// Usage: t('party.segmented_control.characters')
8. Update Trans Component Usage
The Trans component works differently in next-intl. Change from:
<Trans i18nKey="modals.summons.messages.remove">
Are you sure you want to remove{' '}
<strong>{{ weapon: gridSummon?.object.name[locale] }}</strong> from
your team?
</Trans>
To using rich text formatting:
t.rich('modals.summons.messages.remove', {
weapon: gridSummon?.object.name[locale],
strong: (chunks) => <strong>{chunks}</strong>
})
9. Language Switch
Prefer path-based locale switching over cookie+refresh for clarity and correct URL behavior.
// Using next-intl/navigation
const router = useRouter()
const pathname = usePathname()
function changeLanguage(to: 'en'|'ja') {
router.replace(pathname, {locale: to})
}
You may still set NEXT_LOCALE for persistence, but the router option should be the source of truth.
10. Server Helpers and Metadata
Use server utilities for server-only files and localized metadata.
// In server components/routes (e.g., not-found.tsx)
import {getTranslations} from 'next-intl/server'
const t = await getTranslations('common')
// Localized metadata
export async function generateMetadata({params: {locale}}) {
const t = await getTranslations({locale, namespace: 'common'})
return {title: t('title')}
}
Files to Update
Core Configuration Files
/package.json- Addnext-intldependency/i18n.config.ts- Create new configuration file/middleware.ts- Compose next-intl + auth middleware/i18n/request.ts- Ensure imports from../i18n.configare correct/next.config.js- Removei18nproperty (avoid conflict with middleware)/next-i18next.config.js- Remove after migration
App Router Files (7 files)
/app/[locale]/layout.tsx- New; add NextIntlClientProvider/app/new/NewPartyClient.tsx- Update imports and usage/app/[username]/ProfilePageClient.tsx- Update imports and usage/app/saved/SavedPageClient.tsx- Update imports and usage/app/p/[party]/PartyPageClient.tsx- Update imports and usage/app/teams/TeamsPageClient.tsx- Update imports and usage/app/not-found.tsx- Update imports and usage/app/layout.tsx- If present, keep minimal; remove i18n/lang handling
Component Files (76 files)
All files in /components directory that use translations and/or navigation:
/components/Header/index.tsx/components/ErrorSection/index.tsx/components/party/Party/index.tsx/components/party/PartyHeader/index.tsx/components/party/PartyFooter/index.tsx/components/party/PartySegmentedControl/index.tsx/components/party/PartyDropdown/index.tsx/components/party/EditPartyModal/index.tsx/components/party/PartyVisibilityDialog/index.tsx/components/character/CharacterUnit/index.tsx/components/character/CharacterGrid/index.tsx/components/character/CharacterModal/index.tsx/components/character/CharacterHovercard/index.tsx/components/character/CharacterConflictModal/index.tsx/components/character/CharacterSearchFilterBar/index.tsx/components/weapon/WeaponUnit/index.tsx/components/weapon/WeaponGrid/index.tsx/components/weapon/WeaponModal/index.tsx/components/weapon/WeaponHovercard/index.tsx/components/weapon/WeaponConflictModal/index.tsx/components/weapon/WeaponKeySelect/index.tsx/components/weapon/WeaponSearchFilterBar/index.tsx/components/summon/SummonUnit/index.tsx/components/summon/SummonGrid/index.tsx/components/summon/SummonHovercard/index.tsx/components/summon/SummonSearchFilterBar/index.tsx/components/job/JobSection/index.tsx/components/job/JobDropdown/index.tsx/components/job/JobSkillItem/index.tsx/components/job/JobAccessoryPopover/index.tsx/components/job/JobSkillSearchFilterBar/index.tsx/components/auth/LoginModal/index.tsx/components/auth/SignupModal/index.tsx/components/auth/AccountModal/index.tsx/components/raids/RaidCombobox/index.tsx/components/raids/RaidItem/index.tsx/components/search/SearchModal/index.tsx/components/filters/FilterBar/index.tsx/components/filters/FilterModal/index.tsx/components/mastery/AwakeningSelectWithInput/index.tsx/components/mastery/AxSelect/index.tsx/components/mastery/ExtendedMasterySelect/index.tsx/components/uncap/UncapIndicator/index.tsx/components/uncap/TranscendencePopover/index.tsx/components/uncap/TranscendenceStar/index.tsx/components/uncap/TranscendenceFragment/index.tsx/components/extra/GuidebookUnit/index.tsx/components/extra/GuidebooksGrid/index.tsx/components/extra/ExtraWeaponsGrid/index.tsx/components/extra/ExtraSummonsGrid/index.tsx/components/reps/CharacterRep/index.tsx/components/reps/GridRep/index.tsx/components/toasts/UpdateToast/index.tsx/components/toasts/UrlCopiedToast/index.tsx/components/toasts/RemixedToast/index.tsx/components/dialogs/RemixTeamAlert/index.tsx/components/dialogs/DeleteTeamAlert/index.tsx/components/common/Editor/index.tsx/components/common/SelectWithInput/index.tsx/components/common/ToolbarButton/index.tsx/components/common/MentionTypeahead/index.tsx/components/ElementToggle/index.tsx/components/MentionList/index.tsx/components/about/AboutPage/index.tsx/components/about/AboutHead/index.tsx/components/about/RoadmapPage/index.tsx/components/about/UpdatesPage/index.tsx/components/about/ContentUpdate/index.tsx/components/about/updates/ContentUpdate2022/index.tsx/components/about/updates/ContentUpdate2023/index.tsx/components/about/updates/ContentUpdate2024/index.tsx/components/head/NewHead/index.tsx/components/head/ProfileHead/index.tsx/components/head/SavedHead/index.tsx/components/head/TeamsHead/index.tsx/components/party/PartyHead/index.tsx
Pages Router Files (temporary)
Plan to remove Pages Router usage. Temporarily keep while migrating:
/pages/_app.tsx- Keep until all pages are on App Router/pages/about.tsx- Keep until migrated
Task List
-
Setup and Configuration
- Install next-intl package (v4.3.5)
- Create
/i18n.config.tswith locale configuration - Create/update
/middleware.tsfor locale routing (composed with auth) - Update
/next.config.jswithcreateNextIntlPluginwrapper - Create
/i18n/navigation.tswithcreateNavigationfor locale-aware routing
-
Localized Layout
- Create
/app/[locale]/layout.tsxwithunstable_setRequestLocale - Load messages with
getMessages()and wrap withNextIntlClientProvider - Keep
/app/layout.tsxminimal (no lang/i18n)
- Create
-
Update App Router Pages (7+ files)
- Update
/app/new/NewPartyClient.tsx - Update
/app/[username]/ProfilePageClient.tsx - Update
/app/saved/SavedPageClient.tsx - Update
/app/p/[party]/PartyPageClient.tsx - Update
/app/teams/TeamsPageClient.tsx - Update
/app/not-found.tsx - Update
/app/components/Header.tsx
- Update
-
Update Component Imports (76 files)
- Partial: 54/76 files updated to use
next-intl - Fix remaining 8 files with
react-i18nextimports - Fix remaining 11 files with
next-i18nextimports - Replace
next/linkandnext/navigationwith locale-aware navigation (only 1 file done) - Refactor 18 files still using
Transcomponent tot.rich()
- Partial: 54/76 files updated to use
-
Testing and Verification
- Test English locale - App loads successfully!
- Test Japanese locale
- Verify locale switching works
- Check all translation keys render correctly
- Test dynamic translations with variables
-
Cleanup
- Remove
i18nfromnext.config.js - Remove
/next-i18next.config.js - Remove
next-i18nextfrompackage.json - Update documentation
- Remove
Files Still Requiring Updates
Files with react-i18next imports (8 files): ✅ COMPLETED
- ✅
/components/auth/LoginModal/index.tsx- Fixed import - ✅
/components/filters/FilterModal/index.tsx- Fixed import, commented Trans - ✅
/components/job/JobSkillSearchFilterBar/index.tsx- Fixed import - ✅
/components/party/EditPartyModal/index.tsx- Fixed import, simplified Trans - ✅
/components/party/PartyVisibilityDialog/index.tsx- Fixed import - ✅
/components/raids/RaidCombobox/index.tsx- Fixed import - ✅
/components/search/SearchModal/index.tsx- Fixed import - ✅
/components/weapon/WeaponConflictModal/index.tsx- Fixed import, simplified Trans
Files with next-i18next imports (11 files): ✅ COMPLETED
- ✅
/components/about/AboutPage/index.tsx- Fixed import, simplified Trans - ✅
/components/character/CharacterConflictModal/index.tsx- Fixed import - ✅
/components/character/CharacterModal/index.tsx- Fixed import - ✅
/components/character/CharacterUnit/index.tsx- Fixed import - ✅
/components/dialogs/RemixTeamAlert/index.tsx- Fixed import - ✅
/components/extra/GuidebookUnit/index.tsx- Fixed import - ✅
/components/job/JobSkillItem/index.tsx- Fixed import - ✅
/components/summon/SummonUnit/index.tsx- Fixed import - ✅
/components/toasts/RemixedToast/index.tsx- Fixed import - ✅
/components/weapon/WeaponModal/index.tsx- Fixed import - ✅
/components/weapon/WeaponUnit/index.tsx- Fixed import
Additional files with Trans components but correct imports (3 files):
/components/auth/SignupModal/index.tsx- Imports correct but Trans is commented out/components/uncap/TranscendencePopover/index.tsx/components/uncap/TranscendenceStar/index.tsx/components/uncap/UncapIndicator/index.tsx
Navigation Updates Required:
- Most components still use
next/navigationinstead of~/i18n/navigation - Only 1 component currently uses the locale-aware navigation
- The navigation helper exists at
/i18n/navigation.tsusingcreateNavigation
Success Criteria
- All translation keys render as localized strings (not raw keys)
- Locale switching between English and Japanese works and preserves locale in URLs
- Dynamic translations with variables work correctly
- No console errors related to i18n
- App Router pages work correctly; Pages Router pages are removed or temporarily functional during migration
Risks and Mitigation
-
Risk: Middleware composition can cause routing conflicts
- Mitigation: Run next-intl middleware first; strip locale before auth checks; unify matcher
-
Risk: Breaking Pages Router pages during transition
- Mitigation: Keep Pages Router temporarily; schedule removal after App Router parity
-
Risk: Trans component refactoring may be complex
- Mitigation: Start with simple replacements; handle rich content with
t.rich()gradually
- Mitigation: Start with simple replacements; handle rich content with
-
Risk: Large number of files to update
- Mitigation: Batch replace imports; test incrementally; prioritize high-traffic routes
Notes
- The existing translation files in
/public/locales/can be reused without changes - The
i18n/request.tsfile already uses next-intl; ensure it imports from the rooti18n.config.ts - Use
next-intl/navigationeverywhere for links and routers to preserve locale - Remove
i18nfromnext.config.jsto avoid conflicts with next-intl middleware routing - Consider using a codemod/script to automate import updates across 76+ files