Skip to content

Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.

The Problem

The <Trans> component renders the message ID instead of translated text:

import { Trans } from '@lingui/react';

<Trans id="welcome">Welcome to our app</Trans>
// Renders: "welcome" instead of the translated text

Or message extraction produces an empty catalog:

npx lingui extract
# Catalog for "en": 0 messages extracted

Or the Lingui macro throws a build error:

SyntaxError: Using the macro requires configuring the Lingui compiler

Why This Happens

Lingui is a compile-time i18n framework that uses macros to extract translatable messages from source code:

  • Lingui macros require a compiler plugin@lingui/macro uses babel or SWC macros that transform <Trans> and t calls at build time. Without the compiler plugin, macros aren’t processed and throw syntax errors.
  • Messages must be extracted before translationlingui extract scans source files for macro usage and creates message catalogs (.po or .json). Without extraction, catalogs are empty.
  • Catalogs must be compiledlingui compile transforms human-readable catalogs into optimized JavaScript. Without compilation, the runtime can’t find translations.
  • The I18nProvider must wrap the app — without the provider and active locale, all messages render as their IDs or source text.

The single biggest source of confusion is that Lingui has two macro packages now and the import paths matter. Old code used import { Trans } from '@lingui/macro'. Recent versions split this into @lingui/react/macro (for JSX) and @lingui/core/macro (for plain functions). Copy-pasted snippets from older tutorials will compile because the names still exist, but they pull from the runtime package instead of the macro package — and the runtime <Trans> requires a pre-extracted id prop, so passing it children does nothing. The result is your message ID renders verbatim. Always import macros from the /macro subpath.

The second class of failure is bundler integration. Lingui’s macros are transforms — they only run if your bundler is configured to run them. The Vite plugin requires the @lingui/vite-plugin plus a babel plugin for the React preset (or a SWC plugin if you use @vitejs/plugin-react-swc). Webpack needs babel-plugin-macros plus @lingui/babel-plugin-lingui-macro. Next.js with the App Router uses SWC by default, so you need @lingui/swc-plugin registered under experimental.swcPlugins. If any of these are missing, the macro call passes through verbatim and you see “requires configuring the Lingui compiler” at runtime or build.

The third issue is comparing Lingui to other libraries. FormatJS / react-intl uses runtime ICU and ships the formatter — every message goes through the runtime. Lingui compiles ICU into a small per-locale runtime so production bundles are smaller, but you must run lingui compile before every build. If you forget that step, you ship the human-readable .po files (which the runtime cannot read) instead of the compiled JS, and every translation falls back to its source.

Fix 1: Setup with React and Vite

npm install @lingui/core @lingui/react
npm install -D @lingui/cli @lingui/macro @lingui/vite-plugin
// lingui.config.ts
import type { LinguiConfig } from '@lingui/conf';

const config: LinguiConfig = {
  locales: ['en', 'ja', 'es', 'fr'],
  sourceLocale: 'en',
  catalogs: [
    {
      path: '<rootDir>/src/locales/{locale}/messages',
      include: ['src'],
    },
  ],
  format: 'po',  // 'po' | 'json' | 'minimal'
};

export default config;
// vite.config.ts — add the Lingui plugin
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { lingui } from '@lingui/vite-plugin';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ['@lingui/babel-plugin-lingui-macro'],
      },
    }),
    lingui(),
  ],
});

// Or with SWC (faster):
import react from '@vitejs/plugin-react-swc';

export default defineConfig({
  plugins: [
    react({
      plugins: [['@lingui/swc-plugin', {}]],
    }),
    lingui(),
  ],
});
// src/i18n.ts — initialize i18n
import { i18n } from '@lingui/core';

export async function loadCatalog(locale: string) {
  const { messages } = await import(`./locales/${locale}/messages.ts`);
  i18n.load(locale, messages);
  i18n.activate(locale);
}

// Load default locale on startup
loadCatalog('en');

export { i18n };
// src/main.tsx — wrap app with provider
import { I18nProvider } from '@lingui/react';
import { i18n } from './i18n';

function App() {
  return (
    <I18nProvider i18n={i18n}>
      <MainContent />
    </I18nProvider>
  );
}

Fix 2: Use Macros in Components

// Macros are compiled away at build time — zero runtime overhead
import { Trans, Plural, Select } from '@lingui/react/macro';
import { t, msg, plural } from '@lingui/core/macro';

function WelcomePage({ user }: { user: User }) {
  return (
    <div>
      {/* Trans macro — JSX translation */}
      <h1><Trans>Welcome to our app</Trans></h1>

      {/* With variables */}
      <p><Trans>Hello, {user.name}!</Trans></p>

      {/* Pluralization */}
      <p>
        <Plural
          value={user.messageCount}
          zero="No messages"
          one="You have # message"
          other="You have # messages"
        />
      </p>

      {/* Gender/select */}
      <p>
        <Select
          value={user.gender}
          male={<Trans>{user.name} updated his profile</Trans>}
          female={<Trans>{user.name} updated her profile</Trans>}
          other={<Trans>{user.name} updated their profile</Trans>}
        />
      </p>

      {/* Rich text — HTML inside translations */}
      <p>
        <Trans>
          Read our <a href="/terms">Terms of Service</a> and{' '}
          <a href="/privacy">Privacy Policy</a>.
        </Trans>
      </p>
    </div>
  );
}

// t() macro — for non-JSX contexts (attributes, variables)
function SearchInput() {
  return (
    <input
      placeholder={t`Search...`}
      aria-label={t`Search the documentation`}
    />
  );
}

// In plain functions
function getErrorMessage(code: string): string {
  switch (code) {
    case 'NOT_FOUND': return t`Resource not found`;
    case 'UNAUTHORIZED': return t`You must be logged in`;
    default: return t`Something went wrong`;
  }
}

// msg() — define messages for later use
const messages = {
  title: msg`Dashboard`,
  subtitle: msg`Welcome back, ${name}`,
};

// Use later with i18n._(messages.title)

Fix 3: Extract and Translate

# Step 1: Extract messages from source code
npx lingui extract

# Output:
# Catalog statistics for en:
# +----------+-------------+---------+
# | Language | Total count | Missing |
# +----------+-------------+---------+
# | en       | 42          | 0       |
# | ja       | 42          | 42      |
# | es       | 42          | 42      |
# +----------+-------------+---------+

# Step 2: Translate the catalogs
# Edit src/locales/ja/messages.po (or .json)

# Step 3: Compile catalogs to JavaScript
npx lingui compile

# This generates optimized .ts files from .po catalogs
# src/locales/ja/messages.po
msgid "Welcome to our app"
msgstr "アプリへようこそ"

msgid "Hello, {name}!"
msgstr "こんにちは、{name}さん!"

msgid "{0, plural, zero {No messages} one {You have # message} other {You have # messages}}"
msgstr "{0, plural, other {{0}件のメッセージ}}"

msgid "Search..."
msgstr "検索..."
// package.json — i18n scripts
{
  "scripts": {
    "i18n:extract": "lingui extract",
    "i18n:compile": "lingui compile",
    "i18n": "lingui extract && lingui compile",
    "prebuild": "npm run i18n:compile"
  }
}

Fix 4: Language Switching

'use client';

import { useLingui } from '@lingui/react';
import { loadCatalog } from '@/i18n';

function LanguageSwitcher() {
  const { i18n } = useLingui();

  async function changeLanguage(locale: string) {
    await loadCatalog(locale);
    // i18n is already activated in loadCatalog
  }

  const locales = [
    { code: 'en', label: 'English' },
    { code: 'ja', label: '日本語' },
    { code: 'es', label: 'Español' },
  ];

  return (
    <select
      value={i18n.locale}
      onChange={(e) => changeLanguage(e.target.value)}
    >
      {locales.map(({ code, label }) => (
        <option key={code} value={code}>{label}</option>
      ))}
    </select>
  );
}

Fix 5: Next.js App Router

// next.config.mjs
const nextConfig = {
  experimental: {
    swcPlugins: [['@lingui/swc-plugin', {}]],
  },
};

export default nextConfig;
// app/[lang]/layout.tsx
import { I18nProvider } from '@lingui/react';
import { loadCatalog, i18n } from '@/i18n';

export default async function LangLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  await loadCatalog(lang);

  return (
    <I18nProvider i18n={i18n}>
      {children}
    </I18nProvider>
  );
}

export function generateStaticParams() {
  return [{ lang: 'en' }, { lang: 'ja' }, { lang: 'es' }];
}

Fix 6: Integration with Translation Services

# Export for translation services (Crowdin, Lokalise, etc.)
npx lingui extract --format po
# .po files are the standard format for translation tools

# Or use JSON format
# lingui.config.ts: format: 'json'
npx lingui extract
# Generates .json catalogs compatible with most translation platforms

# After translators update the files:
npx lingui compile

Fix 7: Platform Differences — Vite, Webpack, Next.js SWC, and React Native

The macro transform has to run, full stop. The exact integration depends on which bundler you use.

Vite + babel (@vitejs/plugin-react): pass the macro as a babel plugin to the React plugin, then add lingui(). The babel plugin handles macros; lingui() handles the .po -> JS compile step at build time so you do not have to run lingui compile manually before each vite build.

Vite + SWC (@vitejs/plugin-react-swc): SWC is faster but does not understand babel macros. Use @lingui/swc-plugin registered via react({ plugins: [['@lingui/swc-plugin', {}]] }). Note: SWC plugins must match the SWC core version used by @vitejs/plugin-react-swc. A mismatch produces a cryptic “failed to load wasm plugin” error.

Webpack (CRA, custom configs): add @lingui/babel-plugin-lingui-macro to your babel config or use babel-plugin-macros so the @lingui/macro import is resolved automatically. CRA users have to eject or use craco because the default babel preset does not include the macro plugin.

Next.js extract: npx lingui extract works across all of these, but with the Next.js App Router and SWC plugins, the macro transform happens during the build pass. If you call lingui extract after a production build, the extractor may scan the transformed code instead of the source. Always run extract against your src/ directory directly: lingui extract --files-from <(git ls-files 'src/**/*.tsx').

React Native compatibility: Lingui works on React Native because the macros transform at build time and the runtime is plain JS. The catch is the catalog loader: import(./locales/${locale}/messages.ts) does dynamic import, and Metro bundler does not support arbitrary dynamic imports unless you whitelist them via metro.config.js. The Lingui docs recommend a switch statement that imports each locale explicitly:

async function loadCatalog(locale: string) {
  switch (locale) {
    case 'ja': return (await import('./locales/ja/messages')).messages;
    case 'es': return (await import('./locales/es/messages')).messages;
    default:  return (await import('./locales/en/messages')).messages;
  }
}

Locale lazy load patterns: Lingui can lazy-load catalogs, which keeps the initial bundle small. The trade-off is that you must await loadCatalog(locale) before rendering localized content. In Suspense-aware frameworks (Next.js App Router, React Router data routers) you can wrap the loader in a Suspense boundary. In React Native or older React, gate rendering on a boolean until the catalog resolves.

Lingui vs FormatJS / react-intl: if you are evaluating which to adopt, the rule of thumb is:

  • Lingui — compile-time, smaller bundles, macros need a bundler integration, ICU-compatible. Faster to render because formatters are pre-compiled into per-locale runtimes.
  • FormatJS / react-intl — runtime ICU, no compiler step, larger runtime, more flexible for dynamic messages loaded from a CMS. Works without any bundler setup but ships the full ICU formatter.

If your translations live in a CMS and change at runtime, FormatJS is easier. If your translations live in version control and ship with the build, Lingui produces smaller, faster bundles.

Still Not Working?

Macro throws “requires configuring the Lingui compiler” — the babel or SWC plugin isn’t configured. For Vite, add @lingui/babel-plugin-lingui-macro to the React plugin’s babel plugins. For Next.js, add @lingui/swc-plugin to experimental.swcPlugins.

Extraction finds 0 messages — the include path in lingui.config.ts doesn’t match your source files. Check that include: ['src'] covers where your components are. Also verify macros are imported from @lingui/react/macro or @lingui/core/macro (not @lingui/react).

Translations show source text instead of translated text — catalogs aren’t compiled. Run npx lingui compile after translating. Also check that loadCatalog() was called with the correct locale and that i18n.activate() was called.

Runtime error: “i18n instance not found”I18nProvider is missing or doesn’t wrap the component using translations. Ensure the provider is in the layout above all translated components.

SWC plugin fails to load with “failed to load wasm plugin”@lingui/swc-plugin version does not match the SWC core inside @vitejs/plugin-react-swc or next. Pin both to compatible versions; check the SWC plugin compatibility matrix in the package README.

Next.js App Router server components render English even on /ja/ routesloadCatalog(lang) was called but i18n is a module singleton, so two concurrent requests for different locales race. Use the per-request setup pattern from the Lingui Next.js docs, or wrap each route in I18nProvider with a fresh i18n instance per request.

React Native bundle is larger than expected — Metro included every locale because the dynamic import(…${locale}…) could not be analyzed. Replace it with the explicit switch pattern above so Metro can split each catalog into its own chunk.

For related i18n issues, see Fix: i18next Not Working, Fix: next-intl Not Working, Fix: Paraglide Not Working, and Fix: MDX Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles