i18n linting: catch untranslated strings before they ship

Add i18n checks to your CI pipeline so new hardcoded strings get caught before production. ESLint rules, AST-based scanning, and coverage gates explained.

4 min read← All articles

You wouldn't ship code without running your linter. But most teams ship code with hardcoded, untranslated strings on every release — because they don't have an i18n linter in their CI pipeline.

i18n linting is the practice of automatically checking your codebase for strings that should be translated but aren't. It catches regressions before they reach production: a developer adds a new feature, hardcodes the button labels in English, and the i18n lint step flags them in the PR before the code merges.

Here's how to set it up, from basic ESLint rules to comprehensive AST-based detection.

Level 1: ESLint rules

The simplest i18n lint is an ESLint rule that flags hardcoded strings in JSX.

eslint-plugin-i18next

Flags strings not wrapped in i18next's t() function:

{
  "plugins": ["i18next"],
  "rules": {
    "i18next/no-literal-string": ["warn", {
      "markupOnly": true,
      "ignoreAttribute": ["className", "style", "type", "key", "id", "data-testid"]
    }]
  }
}

react/jsx-no-literals

A stricter rule that flags all JSX text content:

{
  "rules": {
    "react/jsx-no-literals": ["warn", {
      "noStrings": true,
      "allowedStrings": ["—", "·", "×"]
    }]
  }
}

Limitations of ESLint-based linting

ESLint rules do run on an AST, but these i18n rules mostly judge each literal by its node type and a list of ignored attributes — not by whether it ends up in the UI. This means:

  • High false positive rate. className="flex", type="button", href="/about" all get flagged unless you manually exclude every non-translatable attribute.
  • Incomplete coverage. Strings in function arguments, template literals, and non-JSX contexts are often missed.
  • Framework-blind. These JSX rules don't cover Astro templates, SvelteKit template blocks, or React Native API calls like Alert.alert().
  • Noise fatigue. Teams that turn on jsx-no-literals often turn it off again because real issues get buried under warnings.

Verdict: Good as a basic gate. Not reliable enough to be your primary i18n lint.

Level 2: AST-based detection with Polyglot

Polyglot's scanner uses tree-sitter to parse your source code into a full syntax tree, then walks it to find strings based on their structural context. This cuts down false positives — className values aren't flagged because the AST knows they're className attributes, not user-facing text.

Running in CI

Add the open Polyglot Action to your GitHub Actions workflow:

# .github/workflows/i18n-lint.yml
name: i18n Lint
on: [pull_request]

jobs:
  i18n:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check for new untranslated strings
        uses: polyglot-i18n/[email protected]
        with:
          check-mode: differential

What this gives you

Differential findings. The Action compares your PR against its base and separates new strings from existing debt and resolved findings. It needs a committed polyglot.toml. The default no-new policy fails the job only when a PR adds a finding. Want to watch first? Set preset = "informational" under [ci] in polyglot.toml while you establish a baseline, then switch back and make it a required check to block merges. On Team and Scale, Polyglot Automation (early access) adds a native GitHub Check with shared policy and history.

Fewer false positives. Because detection is AST-based, the CI check filters out CSS classes, import paths, and URLs by structure. Findings are still candidates, so the occasional one needs a human call, but there's far less noise to wade through.

Level 3: Coverage tracking over time

Beyond per-PR linting, polyglot coverage reports, per target language, how many catalog keys have translations. Run it on your default branch (or read the Action's average_coverage output) to measure progress:

# Current coverage
polyglot coverage

Output:

Translation Coverage (76 strings across 214 files)
────────────────────────────────────────────────────────────

  Language     Translated    Coverage
  ─────────── ──────────── ─────────
  fr          1247/1323      94.3%  [█████████░]
  de          1241/1323      93.8%  [█████████░]
  ja          1205/1323      91.1%  [█████████░]

  Overall: 93.0% average coverage

Tracking coverage as a metric — like test coverage — makes i18n visible to the team. When coverage is a number that shows up in PRs and dashboards, it gets attention. Add --by-file for a per-file breakdown, or --format badge for a README badge.

Combining ESLint + Polyglot

The best setup uses both:

  1. ESLint i18next/no-literal-string as a development-time hint — it runs in your editor and nudges you to wrap strings as you write them.
  2. Polyglot's differential check in CI as the authoritative gate — it catches framework patterns ESLint misses, and it doesn't make historical debt a prerequisite for adoption.

This gives you fast feedback during development (ESLint in your editor) and context-aware detection in CI (Polyglot in GitHub Actions). The ESLint rule is a guardrail; Polyglot is the gate.

Getting started

# Install
curl -fsSL https://getpolyglot.ai/install.sh | bash

# Detect untranslated strings (exit 1 = candidates found, 2 = scan incomplete)
polyglot scan

# Create polyglot.toml with your target languages
polyglot init --local

# See current coverage
polyglot coverage

# Run the same differential check CI runs, against your main branch
polyglot check --base origin/main

The scanner is free, requires no account, and runs locally. Add it to your CI pipeline today and stop shipping hardcoded strings.

Start in your terminal

Stop hunting for untranslated strings.

Install the CLI, run a scan, and see exactly what you're missing. Free, no account required.

$curl -fsSL https://getpolyglot.ai/install.sh | bash
i18n linting: catch untranslated strings before they ship - Polyglot Blog | Polyglot