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.
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.
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.
Several ESLint plugins can flag hardcoded strings in JSX:
t() callsjsx-no-literals rule flags all JSX text<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:
Alert.alert("Error"))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.
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:
placeholder, aria-label, alt, title) → translatableclassName values → not translatable (attribute name check)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:
Alert.alert("Title", "Message"))<style> and <script> blocks ignored{#if}, {#each})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.
| Approach | Setup time | False positives | Understands context | Ongoing effort |
|---|---|---|---|---|
| Regex | 5 minutes | Very high | No | Manual review |
| ESLint | 30 minutes | High | Partly (JSX node types) | Suppression management |
| Polyglot (AST) | 2 minutes | Lower | Yes (syntax tree + framework templates) | Review new findings per PR |
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.
# 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
Install the CLI, run a scan, and see exactly what you're missing. Free, no account required.