CoreMVP
Customization

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:

  1. Creating the i18n configuration
  2. Adding the [lang] dynamic route segment
  3. Setting up language-specific content folders
  4. Creating translation dictionaries
  5. Updating components to use translations
  6. Adding middleware for locale detection

Prerequisites

The template already includes the required dependencies:

  • fumadocs-core - Provides i18n utilities
  • @formatjs/intl-localematcher - Locale matching
  • negotiator - HTTP content negotiation

Step 1: Create i18n Configuration

Create src/lib/i18n-config.ts:

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.tsx

Target Structure

src/app/
  ├── [lang]/
  │   ├── (landing)/
  │   ├── docs/
  │   ├── blog/
  │   ├── signin/
  │   ├── signup/
  │   └── layout.tsx
  ├── api/              # API routes stay at root
  └── sitemap.ts

Create the Lang Layout

Create src/app/[lang]/layout.tsx:

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:

src/components/provider.tsx
'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:

src/lib/source.ts
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.mdx

Step 6: Create Translation Dictionaries

Create dictionary files for UI translations:

src/dictionaries/en.json
{
  "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"
  }
}
src/dictionaries/fr.json
{
  "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:

src/lib/dictionaries.ts
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:

src/proxy.ts
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:

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:

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:

src/components/language-toggle.tsx
'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>
  );
}

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

  1. Start the dev server: bun dev
  2. Visit http://localhost:3000 - should redirect to /en
  3. Visit http://localhost:3000/fr - should show French content
  4. Check that navigation works between languages
  5. 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.json includes the page
  • Verify the source.ts i18n configuration

Middleware Not Running

If locale detection fails:

  • Check middleware.ts or proxy.ts is 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 getDictionary is 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?

On this page