How to find untranslated strings in React and Next.js

Hardcoded strings hiding in your codebase are the #1 reason i18n projects ship incomplete. Here's how to find them — with regex, ESLint, and AST-based detection.

5 min read← All articles

Every developer who has shipped a multilingual app knows the feeling: you translated everything, you tested thoroughly, and then a user in Germany screenshots a page where half the UI is still in English. There were hardcoded strings you missed.

This happens because finding untranslated strings is genuinely hard. A typical Next.js application has hundreds of components, and translatable strings hide in JSX text, props, template literals, error messages, and API responses. Manual auditing is slow and unreliable. Here are three approaches, from basic to structure-aware.

Approach 1: Regex (fast, unreliable)

The simplest approach is to grep for patterns that look like hardcoded strings:

# Find JSX text content (very rough)
grep -rn '>[A-Z][a-z]' --include="*.tsx" src/

# Find string literals in attributes
grep -rn 'placeholder="[A-Z]' --include="*.tsx" src/
grep -rn 'aria-label="[A-Z]' --include="*.tsx" src/

What it catches: Obvious English text in JSX elements and a few known attributes.

What it misses: Template literals, string variables, computed strings, nested components, and anything that doesn't start with a capital letter. It also flags false positives constantly — CSS class names, import paths, config constants, URLs, and any string that happens to match the pattern.

Verdict: Useful for a quick sanity check. Not reliable enough for production i18n.

Approach 2: ESLint rules (better, still incomplete)

Several ESLint plugins can flag hardcoded strings in JSX:

  • eslint-plugin-i18next — flags strings not wrapped in t() calls
  • eslint-plugin-react — jsx-no-literals rule flags all JSX text
  • @formatjs/eslint-plugin — flags strings not wrapped in <FormattedMessage>
// .eslintrc.json
{
  "plugins": ["i18next"],
  "rules": {
    "i18next/no-literal-string": "warn"
  }
}

What it catches: Hardcoded strings in JSX text nodes and some string attributes.

What it misses:

  • Strings in function arguments (e.g., Alert.alert("Error"))
  • Strings in non-JSX contexts (utility functions, constants)
  • Framework-specific patterns (Astro templates, SvelteKit templates)
  • It can't distinguish between user-facing strings and internal strings (config values, CSS, technical identifiers)

The false positive problem: ESLint's jsx-no-literals flags everything, including className="flex", type="button", and href="/about". On a real codebase, the noise-to-signal ratio makes it unusable without extensive eslint-disable comments — which defeat the purpose.

Verdict: Better than regex. Good as a lint gate if you configure ignore patterns carefully. Still misses significant categories of strings.

Approach 3: AST-based detection (context-aware)

AST-based detection parses your source code into a syntax tree and walks it to find strings based on their structural context, not their text content. This is what Polyglot does.

polyglot scan

Trimmed output:

src/app/page.tsx  (2 strings)
────────────────────────────────────────────────────────────
  L8    [text]  Welcome to our platform
  L12   [text]  Get started for free

src/components/Header.tsx  (1 string)
────────────────────────────────────────────────────────────
  L15   [attr]  Search...

src/components/ErrorBoundary.tsx  (1 string)
────────────────────────────────────────────────────────────
  L9    [text]  Something went wrong

Found 47 localization candidates in 12 files (86 files scanned)

How it works: Polyglot uses tree-sitter to parse TSX into a concrete syntax tree. It then walks the tree recursively, checking each node's type and position:

  • JSX text nodes (children of JSX elements) → translatable
  • String literal attributes (placeholder, aria-label, alt, title) → translatable
  • Import specifiers → not translatable (filtered by node type)
  • Object property keys → not translatable
  • className values → not translatable (attribute name check)
  • TypeScript type annotations → not translatable (parent node check)
  • URLs, emails, dates, numbers → not translatable (pattern filter)

Because the detection operates on the AST rather than text, it avoids the most common false positives. A string inside className="flex items-center" is never flagged because the AST knows it's a className attribute. A string inside import "./styles.css" is never flagged because the AST knows it's an import specifier.

What it catches that other approaches miss:

  • Strings in React Native API calls (Alert.alert("Title", "Message"))
  • Strings in Astro templates, with <style> and <script> blocks ignored
  • Strings in SvelteKit template blocks ({#if}, {#each})
  • Props that are user-facing but not in the standard list (e.g., headerTitle, tabBarLabel)

Verdict: The most context-aware approach. Results are still candidates for you to review, not proof that every UI string was found — strings in utility modules, API responses, or Astro frontmatter can still need a manual look.

Comparison

ApproachSetup timeFalse positivesUnderstands contextOngoing effort
Regex5 minutesVery highNoManual review
ESLint30 minutesHighPartly (JSX node types)Suppression management
Polyglot (AST)2 minutesLowerYes (syntax tree + framework templates)Review new findings per PR

Using detection in CI

The real value of detection isn't running it once — it's running it on every PR so new hardcoded strings get caught in review. The open Polyglot Action compares each pull request against its base:

# .github/workflows/i18n.yml
name: i18n Check
on: [pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: polyglot-i18n/[email protected]
        with:
          check-mode: differential

Differential mode needs a committed polyglot.toml. New strings get flagged in the PR that introduces them; your existing backlog is counted separately, so adopting the check doesn't mean fixing history first. On Team and Scale, Polyglot Automation (early access) adds a native GitHub Check, and after someone reviews the generated translations, it opens a separate catalog-only PR for your team to merge.

Getting started

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

# Scan your project (free, no account required)
polyglot scan

# Output JSON for tooling (file paths, line numbers, element types)
polyglot scan --format json

scan exits 1 when it finds candidates and 2 when the scan is empty, incomplete, or unsupported, so CI can tell "found strings" from "couldn't check."

The scanner is free, needs no account, and infers your setup without a config file. Detection covers Next.js, Astro, SvelteKit, React Native, Vue, Angular, and Flutter. Run it once and see what it finds.

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
How to find untranslated strings in React and Next.js - Polyglot Blog | Polyglot