Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Internationalization Support

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Fumadocs provides comprehensive internationalization support for multi-language documentation projects, coordinating server-side locale negotiation, request proxy middleware, and client React context providers. It bridges server-rendered storage collections with reactive UI components, enabling seamless language switching, localized navigation trees, and extensible translation dictionaries across base and Radix UI layouts.

Sources: packages/core/src/i18n/middleware.ts:56-110, packages/base-ui/src/contexts/i18n.tsx:33-72, packages/radix-ui/src/contexts/i18n.tsx:49-89, packages/base-ui/src/i18n.tsx:52-71, packages/core/src/source/loader.ts:303-346

Routing Middleware and Negotiation

Middleware Options and URL Formatting

The createI18nMiddleware function generates a Next.js proxy middleware to handle internationalization routing, language detection, and header negotiation based on MiddlewareOptions and core I18nConfig.

interface MiddlewareOptions extends I18nConfig {
  format?: URLFormatter | ((locale: string, pathname: string) => string);
  cookieName?: string;
}
 
export interface URLFormatter {
  get: (url: NextURL) => string | undefined;
  add: (url: NextURL, locale: string) => URL;
  remove: (url: NextURL) => URL;
}

Sources: packages/core/src/i18n/middleware.ts:7-36

Option / MethodTypeDefaultPurpose
languagesstring[]RequiredSupported language codes for the project
defaultLanguagestringRequiredFallback language used when negotiation fails or matches defaults
formatURLFormatter | FunctionDefaultFormatterFormatter object or custom function to add, remove, or retrieve locale prefixes
cookieNamestring'FD_LOCALE'Cookie key storing the active locale when hideLocale is set to 'always'
hideLocale'never' | 'always' | 'default-locale''never'Strategy for hiding locale segments in the request pathname
URLFormatter.get(url: NextURL) => string | undefinedDefaultFormatterExtracts the locale segment from the request URL pathname
URLFormatter.add(url: NextURL, locale: string) => URLDefaultFormatterInserts the locale prefix and project base path into the URL
URLFormatter.remove(url: NextURL) => URLDefaultFormatterStrips the explicit locale segment from the URL pathname

Sources: packages/core/src/i18n/middleware.ts:38-75

Request Routing and Negotiation Flow

When a request enters the proxy middleware, the handler extracts the current path locale using formatter.get(url). If the extracted segment is not present in the configured languages array, it is treated as undefined.

export const DefaultFormatter: URLFormatter = {
  get(url) {
    const segs = url.pathname.split('/');
    if (segs.length > 1 && segs[1]) return segs[1];
  },
  add(url, locale) {
    const next = new URL(url);
    next.pathname = `${url.basePath}/${locale}/${url.pathname}`.replaceAll(/\/+/g, '/');
    return next;
  },
  remove(url) {
    const next = new URL(url);
    const pathname = url.pathname.split('/').slice(2).join('/');
    next.pathname = `${url.basePath}/${pathname}`.replaceAll(/\/+/g, '/');
    return next;
  },
};

Sources: packages/core/src/i18n/middleware.ts:38-54

Depending on the configured hideLocale strategy, requests without a valid path locale trigger different routing behaviors:

  • If hideLocale is 'default-locale', the middleware rewrites the request to the URL formatted with defaultLanguage.
  • If hideLocale is 'always', it retrieves the locale from the request cookie specified by cookieName, falling back to language negotiation via @formatjs/intl-localematcher and getNegotiator, then rewrites to the formatted URL.
  • Otherwise, it issues an HTTP redirect to the preferred negotiated locale URL.
    if (!pathLocale) {
      if (hideLocale === 'default-locale') {
        return NextResponse.rewrite(formatter.add(url, defaultLanguage));
      }
 
      const finalLanguages = getNegotiator(request).languages(languages);
      const preferred = matchLocale(finalLanguages, languages, defaultLanguage);
      if (hideLocale === 'always') {
        const locale = request.cookies.get(cookieName)?.value ?? preferred;
 
        return NextResponse.rewrite(formatter.add(url, locale));
      }
 
      return NextResponse.redirect(formatter.add(url, preferred));
    }

Sources: packages/core/src/i18n/middleware.ts:82-96

Warning

When hideLocale is set to 'always' or 'default-locale' (when matching the default language), explicit locale prefixes in the URL are stripped via formatter.remove(url) and a redirect response is returned while simultaneously persisting the active locale code into the cookieName cookie.

Sources: packages/core/src/i18n/middleware.ts:98-106

Client Context and Localization Hooks

Translation APIs and Configuration Helpers

Client-side internationalization relies on React context providers and navigation hooks implemented identically across both packages/base-ui and packages/radix-ui. The LocaleContext supplies active locale state, available language items, and an onChange updater function accessed through the useI18n hook.

interface LocaleItem {
  name: string;
  locale: string;
}
 
interface LocaleContextType {
  locale?: string;
  onChange?: (v: string) => void;
  locales?: LocaleItem[];
}
 
const LocaleContext = createContext<LocaleContextType>({});
 
export function useI18n(): LocaleContextType {
  return use(LocaleContext);
}

Sources: packages/base-ui/src/contexts/i18n.tsx:6-21, packages/radix-ui/src/contexts/i18n.tsx:6-21

The I18nProvider component wraps application trees with both LocaleContext and @fuma-translate/react's TranslationProvider. When onLocaleChange is omitted, the provider's built-in locale switcher mutates the current pathname by replacing or prepending the target language segment before calling router.push.

export function I18nProvider({
  locales = [],
  locale,
  onLocaleChange,
  children,
  translations = Empty,
}: I18nProviderProps) {
  const router = useRouter();
  const pathname = usePathname();
  const onChange = (value: string) => {
    if (onLocaleChange) {
      return onLocaleChange(value);
    }
    const segments = pathname.split('/').filter((v) => v.length > 0);
 
    if (segments.length === 0 || segments[0] !== locale) {
      segments.unshift(value);
    } else {
      segments[0] = value;
    }
 
    router.push(`/${segments.join('/')}`);
  };

Sources: packages/base-ui/src/contexts/i18n.tsx:33-55, packages/radix-ui/src/contexts/i18n.tsx:49-72

Note

I18nProvider maintains an onChangeRef synchronized with the latest onChange callback on every render, ensuring stable memoized context references passed to descendant components.

Sources: packages/base-ui/src/contexts/i18n.tsx:56-69, packages/radix-ui/src/contexts/i18n.tsx:73-86

Helper functions in packages/base-ui/src/i18n.tsx and packages/radix-ui/src/i18n.tsx bridge core translation dictionaries with UI provider properties. The uiTranslations extension injects localized UI keys, while i18nProvider resolves language fallbacks and maps supported language codes to display names.

Function / HelperSignaturePurpose
uiTranslations() => TranslationExtension<keyof Translations>Returns compiled UI translation keys from package assets
i18nProvider(translations, lang?) => I18nProviderPropsGenerates provider properties including locale, translations, and mapped locales array
defineI18nUI(config, localeTranslations) => I18nUIConfigExtends an I18nConfig object with a custom provider factory method

Sources: packages/base-ui/src/i18n.tsx:12-46, packages/radix-ui/src/i18n.tsx:12-46

Warning

When resolving language display names in i18nProvider, the implementation defaults to 'English' if t.get(code).displayName is undefined.

Sources: packages/base-ui/src/i18n.tsx:35-39, packages/radix-ui/src/i18n.tsx:35-39

Language Selection Interface Components

Language Switcher and Popover Presentation

The LanguageSelect component provides an interactive dropdown interface using primitive popover components. It retrieves the active language context via useI18n() and throws an error if context.locales is uninitialized. The trigger element renders with configurable button variants and applies an accessibility label through @fuma-translate/react.

export function LanguageSelect({
  className,
  variant = 'ghost',
  children,
  ...rest
}: LanguageSelectProps): React.ReactElement {
  const context = useI18n();
  const t = useTranslations({ note: 'language switcher' });
  if (!context.locales) throw new Error('Missing `<I18nProvider />`');
 
  const chooseLanguage = t('Choose a language');
 
  return (
    <Popover>
      <PopoverTrigger
        aria-label={t('Choose a language', { note: 'aria-label' })}
        className={(s) =>
          cn(buttonVariants({ variant }), 'gap-1.5 p-1.5', s.open && 'bg-fd-accent', className)
        }
        {...rest}
      >
        {children}
      </PopoverTrigger>
      <PopoverContent className="flex flex-col gap-0.5 p-1">
        <p className="p-2 text-xs font-medium text-fd-muted-foreground">{chooseLanguage}</p>

Sources: packages/base-ui/src/layouts/shared/slots/language-select.tsx:14-38

export function LanguageSelect({
  className,
  variant = 'ghost',
  children,
  ...rest
}: LanguageSelectProps): React.ReactElement {
  const context = useI18n();
  const t = useTranslations({ note: 'language switcher' });
  if (!context.locales) throw new Error('Missing `<I18nProvider />`');
 
  const chooseLanguage = t('Choose a language');
 
  return (
    <Popover>
      <PopoverTrigger
        aria-label={t('Choose a language', { note: 'aria-label' })}
        className={cn(
          buttonVariants({ variant }),
          'gap-1.5 p-1.5 data-[state=open]:bg-fd-accent',
          className,
        )}
        {...rest}
      >
        {children}
      </PopoverTrigger>
      <PopoverContent className="flex flex-col gap-0.5 p-1">
        <p className="p-2 text-xs font-medium text-fd-muted-foreground">{chooseLanguage}</p>

Sources: packages/radix-ui/src/layouts/shared/slots/language-select.tsx:14-40

Warning

Both package implementations require a parent <I18nProvider />; omitting the provider causes LanguageSelect to throw an error immediately during render when context.locales is missing.

Sources: packages/base-ui/src/layouts/shared/slots/language-select.tsx:22-22, packages/radix-ui/src/layouts/shared/slots/language-select.tsx:22-22

Locale Selection and Text Display

Inside the popover content, available locales are mapped into interactive buttons. Clicking an item triggers context.onChange?.(item.locale) to update the active language. The currently active locale receives distinct styling (bg-fd-primary/10 text-fd-primary), while unselected options display muted foreground colors with hover states.

<PopoverContent className="flex flex-col gap-0.5 p-1">
  <p className="p-2 text-xs font-medium text-fd-muted-foreground">{chooseLanguage}</p>
  {context.locales.map((item) => (
    <button
      key={item.locale}
      type="button"
      className={cn(
        'px-2 py-1.5 text-start text-sm rounded-lg transition-colors',
        item.locale === context.locale
          ? 'bg-fd-primary/10 text-fd-primary'
          : 'text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
      )}
      onClick={() => {
        context.onChange?.(item.locale);
      }}
    >
      {item.name}
    </button>
  ))}
</PopoverContent>

Sources: packages/base-ui/src/layouts/shared/slots/language-select.tsx:37-56

<PopoverContent className="flex flex-col gap-0.5 p-1">
  <p className="p-2 text-xs font-medium text-fd-muted-foreground">{chooseLanguage}</p>
  {context.locales.map((item) => (
    <button
      key={item.locale}
      type="button"
      className={cn(
        'px-2 py-1.5 text-start text-sm rounded-lg transition-colors',
        item.locale === context.locale
          ? 'bg-fd-primary/10 text-fd-primary'
          : 'text-fd-muted-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
      )}
      onClick={() => {
        context.onChange?.(item.locale);
      }}
    >
      {item.name}
    </button>
  ))}
</PopoverContent>

Sources: packages/radix-ui/src/layouts/shared/slots/language-select.tsx:39-58

The companion LanguageSelectText component extracts the display name of the currently active locale from the i18n context and renders it inside a span element.

export type LanguageSelectTextProps = ComponentProps<'span'>;
 
export function LanguageSelectText(props: LanguageSelectTextProps) {
  const { locales, locale } = useI18n();
  const text = locales?.find((item) => item.locale === locale)?.name;
 
  return <span {...props}>{text}</span>;
}

Sources: packages/base-ui/src/layouts/shared/slots/language-select.tsx:61-68

export type LanguageSelectTextProps = ComponentProps<'span'>;
 
export function LanguageSelectText(props: LanguageSelectTextProps) {
  const { locales, locale } = useI18n();
  const text = locales?.find((item) => item.locale === locale)?.name;
 
  return <span {...props}>{text}</span>;
}

Sources: packages/radix-ui/src/layouts/shared/slots/language-select.tsx:63-70

Translation Presets and Extension Schemas

Structured Dictionary Bundles and Extension Extensions

Fumadocs structures internationalization dictionaries by combining base UI translations with extension tokens from specialized domain packages. Packages like @fumadocs/api-docs, fumadocs-openapi, and @fumadocs/asyncapi export dedicated translation extension functions that load compiled JSON translation keys and return a TranslationExtension object matching keyof Translations. For instance, apiDocsTranslations() reads from @/.translations/keys.json to yield typed translation keys for schema UIs and playgrounds.

import translationKeys from '@/.translations/keys.json';
import type { TranslationExtension } from 'fumadocs-core/i18n';
import type { Translations } from '@/.translations';
 
export type { Translations };
export function apiDocsTranslations(): TranslationExtension<keyof Translations> {
  return { keys: translationKeys as never };
}

Sources: packages/api-docs/src/i18n.ts:1-8

Domain-specific packages such as openapiTranslations() and asyncapiTranslations() compose their own translation keys with shared schema UI keys by invoking apiDocsTranslations() internally and spreading both key arrays into a unified extension object.

import translationKeys from '@/.translations/keys.json';
import type { TranslationExtension } from 'fumadocs-core/i18n';
import type { Translations } from '@/.translations';
import {
  type Translations as SharedTranslations,
  apiDocsTranslations,
} from '@fumadocs/api-docs/i18n';
 
export type { Translations };
export function openapiTranslations(): TranslationExtension<
  keyof Translations | keyof SharedTranslations
> {
  const shared = apiDocsTranslations();
  return { keys: [...shared.keys, ...translationKeys] as never };
}

Sources: packages/openapi/src/i18n.ts:1-16, packages/asyncapi/src/i18n.ts:1-15

Note

The openapiTranslations() function merges shared schema translation keys from @fumadocs/api-docs with OpenAPI-specific keys, ensuring schema UI components and playground controls resolve their labels correctly in multilingual builds. Sources: packages/openapi/src/i18n.ts:11-16

Multilingual Bundling and Legacy Configuration

Multilingual dictionary bundling is illustrated by comprehensive locale definitions such as Traditional Chinese (zh-tw.ts) and Simplified Chinese (zh-cn.ts). These language files aggregate type definitions across all dependent packages and supply localized strings for user interfaces, API playgrounds, OpenAPI operations, and AsyncAPI channels.

import type { TranslationPreset } from 'fumadocs-core/i18n';
import type { Translations as UITranslations } from 'fumadocs-ui/i18n';
import type { Translations as OpenAPITranslations } from 'fumadocs-openapi/i18n';
import type { Translations as AsyncAPITranslations } from '@fumadocs/asyncapi/i18n';
import type { Translations as StoryTranslations } from '@fumadocs/story/i18n';
import type { Translations as APIDocsTranslations } from '@fumadocs/api-docs/i18n';
 
type Translations = UITranslations &
  OpenAPITranslations &
  AsyncAPITranslations &
  StoryTranslations &
  APIDocsTranslations;

Sources: packages/language/src/zh-tw.ts:1-12, packages/language/src/zh-cn.ts:1-12

For backward compatibility with legacy setups, defineI18nOpenAPI wraps an existing I18nUIConfig instance, overriding its provider method to inject partial translation dictionaries for specific locales dynamically during runtime localization lookup.

export function defineI18nOpenAPI<Languages extends string>(
  config: I18nUIConfig<Languages>,
  translations: Partial<Record<NoInfer<Languages>, Partial<Translations>>>,
): I18nUIConfig<Languages> {
  return {
    ...config,
    provider(locale = config.defaultLanguage) {
      const out = config.provider(locale);
      const data = translations[locale as Languages];
      if (data) {
        out.translations = { ...out.translations, ...data };
      }
      return out;
    },
  };
}

Sources: packages/openapi/src/i18n.ts:19-33

Warning

The defineI18nOpenAPI helper is marked as deprecated; developers are advised to use i18n.translations() combined with openapiTranslations() instead of wrapping the i18n UI configuration object. Sources: packages/openapi/src/i18n.ts:18-18

Localized Tree Navigation and Pagination

Tree Context Resolution and Active Page Traversal

The TreeContextProvider component initializes tree navigation by wrapping raw page trees and resolving active hierarchies. It tracks the current location via usePathname() and computes the active breadcrumb path by invoking searchPath(tree.children, pathname), falling back to tree.fallback?.children if necessary.

export function TreeContextProvider({
  tree: rawTree,
  children,
}: {
  tree: PageTree.Root;
  children: ReactNode;
}) {
  const nextIdRef = useRef(0);
  const pathname = usePathname();
 
  const tree = useMemo(() => rawTree, [rawTree.$id]);
  const path = useMemo(() => {
    return (
      searchPath(tree.children, pathname) ??
      (tree.fallback ? searchPath(tree.fallback.children, pathname) : null) ??
      []
    );
  }, [tree, pathname]);
 
  const root = path.findLast((item) => item.type === 'folder' && item.root) ?? tree;
  root.$id ??= String(nextIdRef.current++);
 
  return (
    <TreeContext value={useMemo(() => ({ root, full: tree }) as TreeContextType, [root, tree])}>
      <PathContext value={path}>{children}</PathContext>
    </TreeContext>
  );
}

Sources: packages/base-ui/src/contexts/tree.tsx:17-47, packages/radix-ui/src/contexts/tree.tsx:17-47

Note

Object-typed tree props passed from Server Components can be re-constructed across renders, breaking hook dependencies. TreeContextProvider mitigates this by memoizing on rawTree.$id and assigning a fallback identifier using nextIdRef if $id is uninitialized. Sources: packages/base-ui/src/contexts/tree.tsx:27-40, packages/radix-ui/src/contexts/tree.tsx:27-40

The useFooterItems() hook flattens the active tree root into a linear sequence of non-external page nodes, caching the result in a WeakMap indexed by PageTree.Root or PageTree.Folder. Folder index nodes are traversed first before processing child entries.

const footerCache = new WeakMap<PageTree.Root | PageTree.Folder, PageTree.Item[]>();
 
export function useFooterItems(): PageTree.Item[] {
  const { root } = useTreeContext();
  const cached = footerCache.get(root);
  if (cached) return cached;
 
  const list: PageTree.Item[] = [];
  function onNode(node: PageTree.Node) {
    if (node.type === 'folder') {
      if (node.index) onNode(node.index);
      for (const child of node.children) onNode(child);
    } else if (node.type === 'page' && !node.external) {
      list.push(node);
    }
  }
 
  for (const child of root.children) onNode(child);
  footerCache.set(root, list);
  return list;
}

Sources: packages/base-ui/src/utils/use-footer-items.ts:5-27, packages/radix-ui/src/utils/use-footer-items.ts:5-27

The Footer component consumes this flattened array, locating the active index via isActive(item.url, pathname) to supply previous and next page references for pagination links.

export function Footer({ items, children, className, ...props }: FooterProps) {
  const footerList = useFooterItems();
  const pathname = usePathname();
  const { previous, next } = useMemo(() => {
    if (items) return items;
 
    const idx = footerList.findIndex((item) => isActive(item.url, pathname));
 
    if (idx === -1) return {};
    return {
      previous: footerList[idx - 1],
      next: footerList[idx + 1],
    };
  }, [footerList, items, pathname]);
 
  return (
    <>
      <div
        className={cn(
          '@container grid gap-4',
          previous && next ? 'grid-cols-2' : 'grid-cols-1',
          className,
        )}
        {...props}
      >
        {previous && <FooterItem item={previous} index={0} />}
        {next && <FooterItem item={next} index={1} />}
      </div>
      {children}
    </>
  );
}

Sources: packages/base-ui/src/layouts/flux/page/slots/footer.tsx:25-56

Was this page helpful?