How Internationalization (i18n) Is Implemented in the Lifetrace Next.js Frontend

Lifetrace implements internationalization in its Next.js frontend using Next-Intl, which provides a type-safe, full-stack i18n solution that handles locale detection on the server and translation rendering in React components.

The repository freeu-group/lifetrace contains a Next.js application (located in the free-todo-frontend directory) that delivers a complete internationalization pipeline. This implementation supports multiple languages through JSON message bundles, detects user preferences via cookies and HTTP headers, and enforces compile-time type safety for all translation keys.

Next-Intl Architecture Overview

The i18n stack in Lifetrace consists of five integrated layers working together to provide seamless language switching. Each layer has a distinct responsibility, from detecting the user's locale to rendering translated strings in the UI.

Layer Responsibility Key File
Locale detection Validates cookies and Accept-Language headers against supported locales lib/i18n/request.ts
Framework integration Wraps Next.js configuration to inject i18n context into every request next.config.ts
Message bundles Stores translated strings as JSON files lib/i18n/messages/*.json
Component translation Exposes the useTranslations hook for type-safe string lookup Any *.tsx component
Type definitions Provides IDE autocomplete and compile-time checking via global declarations global.d.ts

Locale Detection and Request Configuration

The entry point for i18n in Lifetrace is lib/i18n/request.ts, which exports a configuration object using getRequestConfig from next-intl/server. This file determines which locale to serve for every incoming request.

// lib/i18n/request.ts
import { cookies, headers } from "next/headers";
import { getRequestConfig } from "next-intl/server";

const SUPPORTED_LOCALES = ["zh", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
const DEFAULT_LOCALE: Locale = "en";

function parseAcceptLanguage(acceptLanguage: string | null): Locale | null {
  if (!acceptLanguage) return null;
  const languages = acceptLanguage
    .split(",")
    .map(l => {
      const [code, qValue] = l.trim().split(";q=");
      return { code: code.split("-")[0].toLowerCase(), q: qValue ? Number.parseFloat(qValue) : 1 };
    })
    .sort((a, b) => b.q - a.q);
  for (const { code } of languages) {
    if (SUPPORTED_LOCALES.includes(code as Locale)) return code as Locale;
  }
  return null;
}

export default getRequestConfig(async () => {
  const cookieStore = await cookies();
  const localeCookie = cookieStore.get("locale")?.value;

  const locale: Locale =
    SUPPORTED_LOCALES.includes(localeCookie as Locale)
      ? localeCookie!
      : parseAcceptLanguage((await headers()).get("accept-language")) ?? DEFAULT_LOCALE;

  return {
    locale,
    messages: (await import(`./messages/${locale}.json`)).default,
  };
});

Priority order for locale resolution:

  1. Cookie value – If the locale cookie exists and matches a code in SUPPORTED_LOCALES, it takes precedence
  2. Accept-Language header – Parsed for quality values (q parameters), sorted by weight, and matched against supported codes
  3. Default fallback – Defaults to "en" if no match is found

The parseAcceptLanguage function normalizes locale codes (e.g., converting zh-CN to zh) and handles quality values to respect user preferences.

Next.js Integration with the Next-Intl Plugin

To apply the request configuration globally, Lifetrace wraps its Next.js configuration using createNextIntlPlugin in next.config.ts. This plugin ensures the i18n context is available during server-side rendering and hydration.

// next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./lib/i18n/request.ts");

const nextConfig: NextConfig = {
  output: "standalone",
  reactStrictMode: true,
  typedRoutes: true,
  // ... additional configuration
};

export default withNextIntl(nextConfig);

The plugin initialization accepts the path to the request configuration file. By exporting the wrapped configuration (withNextIntl(nextConfig)), every page in the application automatically receives the correct locale and messages objects from the request handler. This setup enables type-safe client-side hooks like useTranslations without additional boilerplate.

Component-Level Translation with useTranslations

Components access translated strings through the useTranslations hook provided by next-intl. This hook accepts a namespace corresponding to top-level keys in the JSON message files.

// components/common/ui/LanguageToggle.tsx
import { useTranslations } from "next-intl";

export default function LanguageToggle() {
  const tLang = useTranslations("language");
  const tLayout = useTranslations("layout");

  return (
    <div>
      <label>{tLang("selectLanguage")}</label>
      <button onClick={() => setLocale("zh")}>{tLang("chinese")}</button>
      <button onClick={() => setLocale("en")}>{tLang("english")}</button>
    </div>
  );
}

Namespace organization allows for modular translation files. For example, the language namespace might contain UI labels for the language selector, while the todoDetail namespace contains task-specific strings. This prevents key collisions and enables code splitting by feature.

To switch locales on the client, the application updates the locale cookie and reloads the page:

// utils/localeSwitcher.ts
export async function setLocale(locale: string) {
  document.cookie = `locale=${locale};path=/;max-age=31536000`;
  window.location.reload();
}

This approach ensures the next request triggers getRequestConfig with the new cookie value, loading the appropriate message bundle.

Type Safety and Global Type Declarations

Lifetrace enforces compile-time safety for translation keys through TypeScript module augmentation in global.d.ts. By declaring a global IntlMessages interface that inherits from the message schema, the IDE provides autocomplete and catches missing keys before runtime.

// global.d.ts
import type messages from "./lib/i18n/messages/zh.json";

type Messages = typeof messages;

declare global {
  interface IntlMessages extends Messages {}
}

The type system uses the Chinese message file (zh.json) as the representative schema. Because all message files share the same key structure, TypeScript validates that any key passed to useTranslations("namespace")("key") exists in the schema.

// Example of type checking in action
const t = useTranslations("todoDetail");

// ✅ Valid - key exists in schema
t("title");

// ❌ TypeScript error - key not found in schema
// t("nonexistentKey");

Adding New Languages to the Application

Extending the i18n implementation to support additional languages requires three steps:

  1. Update supported locales – Add the new locale code to the SUPPORTED_LOCALES array in lib/i18n/request.ts
  2. Create message bundle – Add a new JSON file in lib/i18n/messages/ (e.g., fr.json) with the same key hierarchy as existing files
  3. Provide UI option – Add a button or selector that calls setLocale("fr") to write the cookie

No changes are needed in next.config.ts or component files because the architecture dynamically loads message files based on the resolved locale.

Summary

  • Next-Intl provides the core i18n framework for Lifetrace's Next.js frontend, handling both server-side locale detection and client-side translation
  • Locale resolution follows a strict priority: cookie value first, then Accept-Language header parsing with quality value support, falling back to "en"
  • Type safety is enforced globally through global.d.ts, which merges message schemas into the IntlMessages interface for compile-time key validation
  • Message bundles are stored as JSON files in lib/i18n/messages/ and loaded dynamically based on the resolved locale
  • Language switching persists the selection in a locale cookie and reloads the page to trigger the request configuration

Frequently Asked Questions

How does Lifetrace determine which language to display on the first visit?

On the initial request, the application checks for a locale cookie. If absent, it parses the Accept-Language header, sorts languages by quality value (priority), and selects the first match from SUPPORTED_LOCALES. If no match exists, it defaults to English ("en").

What happens if a translation key is missing in one language file?

TypeScript catches missing keys at compile time because global.d.ts enforces that all keys must exist in the message schema. At runtime, Next-Intl returns the key name as a fallback if a translation is missing, though the type system prevents this scenario during development.

Can users switch languages without reloading the page?

The current implementation in free-todo-frontend requires a full page reload after setting the locale cookie via window.location.reload(). This ensures the server re-runs getRequestConfig to load the correct message bundle and re-render the page with the new locale context.

Where are the actual translated strings stored in the repository?

Translation files are located in free-todo-frontend/lib/i18n/messages/. The repository includes zh.json (Chinese) and en.json (English) by default. Each file contains nested objects organized by feature namespace, such as language, layout, and todoDetail.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →