Internationalization (i18n)
Add multi-language support to your CoreMVP application
Adding i18n Support
This guide walks you through adding internationalization (i18n) to your CoreMVP application. By default, the template ships without i18n for simplicity. Follow these steps to add multi-language support.
Overview
Adding i18n involves:
- Creating the i18n configuration
- Adding the
[lang]dynamic route segment - Setting up language-specific content folders
- Creating translation dictionaries
- Updating components to use translations
- Adding middleware for locale detection
Prerequisites
The template already includes the required dependencies:
fumadocs-core- Provides i18n utilities@formatjs/intl-localematcher- Locale matchingnegotiator- HTTP content negotiation
Step 1: Create i18n Configuration
Create src/lib/i18n-config.ts:
import { defineI18n } from 'fumadocs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['en', 'fr', 'de'], // Add your supported languages
parser: 'dir', // Use directory-based structure
});
export type Locale = (typeof i18n)['languages'][number];Step 2: Create the [lang] Route Structure
Move your existing routes into a [lang] dynamic segment.
Current Structure
src/app/
├── (landing)/
├── docs/
├── blog/
├── signin/
├── signup/
└── layout.tsxTarget Structure
src/app/
├── [lang]/
│ ├── (landing)/
│ ├── docs/
│ ├── blog/
│ ├── signin/
│ ├── signup/
│ └── layout.tsx
├── api/ # API routes stay at root
└── sitemap.tsCreate the Lang Layout
Create src/app/[lang]/layout.tsx:
import '@/styles/globals.css';
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Provider } from '@/components/provider';
import { getBaseUrl } from '@/lib/env';
import { i18n } from '@/lib/i18n-config';
const geist = Geist({
variable: '--font-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
metadataBase: new URL(getBaseUrl()),
title: {
default: 'CoreMVP',
template: '%s | CoreMVP',
},
description: 'CoreMVP - the production-ready SaaS starter kit',
};
// Generate static params for all languages
export async function generateStaticParams() {
return i18n.languages.map((locale) => ({ lang: locale }));
}
type RootLayoutParams = {
children: React.ReactNode;
params: Promise<{ lang: string }>;
};
export default async function RootLayout({
children,
params
}: Readonly<RootLayoutParams>) {
const { lang } = await params;
return (
<html
lang={lang}
className={`${geist.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
<body className="antialiased">
<Provider locale={lang}>{children}</Provider>
</body>
</html>
);
}Step 3: Update the Provider
Update src/components/provider.tsx to accept locale:
'use client';
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { ReactNode } from 'react';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { AuthProvider } from '@/components/auth-provider';
import { ErrorBoundary } from '@/components/error-boundary';
import CustomSearchDialog from '@/components/custom-search';
import { i18n } from '@/lib/i18n-config';
interface ProviderProps {
children: ReactNode;
locale?: string;
}
export function Provider({ children, locale = 'en' }: ProviderProps) {
return (
<ErrorBoundary>
<RootProvider
i18n={{
locale,
locales: i18n.languages.map((lang) => ({
locale: lang,
name: lang.toUpperCase(),
})),
translations: {},
}}
search={{
SearchDialog: CustomSearchDialog,
}}
theme={{
enabled: false,
defaultTheme: 'dark',
forcedTheme: 'dark',
}}
>
<AuthProvider>
<TooltipProvider>{children}</TooltipProvider>
</AuthProvider>
</RootProvider>
</ErrorBoundary>
);
}Step 4: Update Fumadocs Source
Update src/lib/source.ts to include i18n:
import { loader } from 'fumadocs-core/source';
import { docs } from 'fumadocs-mdx:collections/server';
import { i18n } from './i18n-config';
export const source = loader({
baseUrl: '/docs',
i18n, // Add this line
source: docs.toFumadocsSource(),
});Step 5: Create Language-Specific Content
Docs Content Structure
Create language folders for your documentation:
src/content/docs/
├── en/
│ ├── index.mdx
│ ├── meta.json
│ └── getting-started/
├── fr/
│ ├── index.mdx
│ ├── meta.json
│ └── getting-started/
└── de/
├── index.mdx
├── meta.json
└── getting-started/Each language folder should have its own meta.json for navigation.
Blog Content Structure
src/content/blogs/
├── en/
│ ├── meta.json
│ └── welcome.mdx
└── fr/
├── meta.json
└── bienvenue.mdxStep 6: Create Translation Dictionaries
Create dictionary files for UI translations:
{
"header": {
"pricing": "Pricing",
"blog": "Blog",
"signin": "Sign In"
},
"footer": {
"product": "Product",
"company": "Company",
"support": "Support",
"getStarted": "Get Started",
"copyright": "All rights reserved."
},
"common": {
"readMore": "Read more",
"learnMore": "Learn more"
}
}{
"header": {
"pricing": "Tarifs",
"blog": "Blog",
"signin": "Connexion"
},
"footer": {
"product": "Produit",
"company": "Entreprise",
"support": "Support",
"getStarted": "Commencer",
"copyright": "Tous droits reserves."
},
"common": {
"readMore": "Lire la suite",
"learnMore": "En savoir plus"
}
}Create a dictionary loader:
import 'server-only';
import type { Locale } from './i18n-config';
const dictionaries: Record<string, () => Promise<any>> = {
en: () => import('@/dictionaries/en.json').then((m) => m.default),
fr: () => import('@/dictionaries/fr.json').then((m) => m.default),
de: () => import('@/dictionaries/de.json').then((m) => m.default),
};
export const getDictionary = async (locale: Locale | string) => {
const loader = dictionaries[locale] ?? dictionaries.en;
return loader();
};Step 7: Add i18n Middleware
Update src/proxy.ts to handle locale detection:
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
import { i18n } from '@/lib/i18n-config';
const i18nMiddleware = createI18nMiddleware(i18n);
export async function proxy(request: NextRequest, event: NextFetchEvent) {
// Handle i18n locale detection and redirection
const middlewareResult = await i18nMiddleware(request, event);
const response = middlewareResult ?? NextResponse.next();
response.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|json)$).*)',
],
};Step 8: Update Page Components
Docs Layout
Update src/app/[lang]/docs/layout.tsx:
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
type DocsLayoutParams = {
children: ReactNode;
params: Promise<{ lang: string }>;
};
export default async function DocsLayoutComponent({
children,
params,
}: Readonly<DocsLayoutParams>) {
const { lang } = await params;
const tree = source.pageTree[lang]; // Access language-specific tree
return (
<DocsLayout tree={tree} {...baseOptions(lang)}>
{children}
</DocsLayout>
);
}Docs Page
Update src/app/[lang]/docs/[[...slug]]/page.tsx:
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
interface PageProps {
params: Promise<{ lang: string; slug?: string[] }>;
}
export default async function Page(props: PageProps) {
const params = await props.params;
const page = source.getPage(params.slug, params.lang); // Pass lang
if (!page) notFound();
const MDX = page.data.body;
return (
<div>
<h1>{page.data.title}</h1>
<MDX />
</div>
);
}
export async function generateStaticParams() {
return source.generateParams(); // Generates params for all languages
}Step 9: Add Language Switcher
Create a language switcher component:
'use client';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { i18n } from '@/lib/i18n-config';
export function LanguageToggle() {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const currentLang = (params?.lang as string) || 'en';
const switchLanguage = (newLang: string) => {
// Replace current lang segment with new one
const newPath = pathname.replace(`/${currentLang}`, `/${newLang}`);
router.push(newPath);
};
return (
<select
value={currentLang}
onChange={(e) => switchLanguage(e.target.value)}
className="bg-background border border-border rounded px-2 py-1"
>
{i18n.languages.map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
);
}Step 10: Update Internal Links
All internal links must now include the language prefix:
// Before
<Link href="/docs/getting-started">Get Started</Link>
// After
<Link href={`/${lang}/docs/getting-started`}>Get Started</Link>For components, pass the lang prop or use the useParams hook:
const params = useParams();
const lang = params?.lang || 'en';Testing Your i18n Setup
- Start the dev server:
bun dev - Visit
http://localhost:3000- should redirect to/en - Visit
http://localhost:3000/fr- should show French content - Check that navigation works between languages
- Verify docs and blog content loads correctly
Common Issues
Content Not Found
If pages return 404:
- Ensure content exists in the language folder
- Check that
meta.jsonincludes the page - Verify the
source.tsi18n configuration
Middleware Not Running
If locale detection fails:
- Check
middleware.tsorproxy.tsis in the correct location - Verify the matcher pattern includes your routes
Missing Translations
If UI shows fallback text:
- Ensure dictionary files exist for all languages
- Check that
getDictionaryis called with the correct locale
Summary
After completing these steps, your app will support:
- Automatic locale detection from browser preferences
- URL-based language switching (
/en/docs,/fr/docs) - Language-specific content for docs and blog
- Translated UI elements via dictionaries
- SEO-friendly alternate language links
Was this page helpful?