Detecting hardcoded strings in a React app
Every localized codebase has strings that never made it into the catalogue. They are not usually in the places you look — the headings and buttons went through t() on day one. They are in an aria-label, in a thrown error that ends up in a toast, in a STATUS_LABELS constant three directories away from any component. In production they appear as English islands in an otherwise German interface.
This page covers three detection methods, what each one catches, and how to turn a finding into a test that keeps it fixed.
Root cause: translation is opt-in
Nothing in React requires text to pass through a translation function. A string literal in JSX renders. That is the whole mechanism, and it means the localized state of a codebase is maintained entirely by convention — which decays exactly as fast as the team grows.
The decay is not uniform. It concentrates in code paths that are written under pressure and read rarely: error handling, empty states, accessibility attributes, and anything added during an incident. Those are the same paths that are hardest to reach in a manual QA pass, which is why they survive several release cycles before anyone notices.
Method one: a lint rule at the boundary
The cheapest check runs in the editor. eslint-plugin-react ships no-unescaped-entities, but the rule you want is react/jsx-no-literals, which flags bare text nodes in JSX.
// eslint.config.js
export default [{
files: ['src/**/*.{tsx,jsx}'],
rules: {
'react/jsx-no-literals': ['error', {
noStrings: true,
allowedStrings: ['·', '—', '/', ':'], // separators, not language
ignoreProps: false, // attributes count too
}],
},
}];
Setting ignoreProps: false is the part most teams skip, and it is where the value is: it extends the rule to alt, title, placeholder and aria-label, which is where the majority of surviving hardcoded text actually lives.
The rule cannot see strings built at runtime, and it will generate noise on genuinely non-linguistic literals. Both are manageable — the allowlist handles separators, and the noise decays to nothing after one cleanup pass.
Method two: render under a pseudo-locale
The second method catches anything the first cannot see, because it works on rendered output rather than source. Under the accented pseudo-locale described in localization testing and pseudolocalization, every catalogue string renders accented. Anything still in plain English came from somewhere else.
import { test, expect } from '@playwright/test';
// Brand names and codes legitimately stay unaccented.
const ALLOW = /^(Acme|API|CSV|PDF|USD|EUR|OK|ID)$/;
const LATIN_RUN = /\b[A-Za-z]{3,}\b/g;
test('no hardcoded strings on the checkout route @pseudo', async ({ page }) => {
await page.goto('/en-XA/checkout');
const text = await page.locator('main').innerText();
const suspects = [...text.matchAll(LATIN_RUN)]
.map((m) => m[0])
.filter((w) => !ALLOW.test(w));
expect(suspects, `unlocalized words: ${suspects.join(', ')}`).toHaveLength(0);
});
The check is deliberately blunt. It reports words, not elements, and the failure message names them — which in practice is enough to find the source with one search. Its limitation is coverage: it only sees routes the test visits, so it is worth running against the same route list your smoke tests already use.
Method three: a proxy over the catalogue
The third method instruments the lookup itself. Wrapping the catalogue in a Proxy records every key that was requested and missed, which finds a different failure — a t('some.key') call whose key does not exist. That renders as either the raw key or a fallback string, and the reader sees English for a reason the first two methods cannot explain.
const missed = new Set<string>();
export const instrument = (catalog: Record<string, string>) =>
new Proxy(catalog, {
get(target, key: string) {
if (!(key in target)) missed.add(key);
return Reflect.get(target, key);
},
});
afterAll(() => {
expect([...missed], 'keys requested but absent from the catalogue').toEqual([]);
});
This belongs in test and development builds only. In production the same information should arrive as telemetry rather than as an assertion — a counter on missed lookups, which is the signal the fallback chain is designed to make safe but not silent.
Verification
A clean run means all three checks are quiet at once.
npx eslint src --max-warnings 0 # no bare literals in JSX
npx playwright test --grep @pseudo # no unaccented words in rendered output
npx vitest run i18n/lookups # no missed catalogue keys
# Expected
# ✓ 0 problems
# ✓ 12 routes rendered under en-XA, 0 unlocalized words
# ✓ 0 missed lookups across 418 keys
When to escalate
If the checks are clean and English still appears in a translated build, the text is not coming from your components. Three sources account for almost all of it.
Server-generated content — emails, PDFs, webhook payloads — renders through a different entry point that your browser tests never touch. Those need their own pass, run against the same pseudo catalogue.
Third-party components ship their own strings. A date picker, a payment form or a file uploader may have its own locale prop that nobody set, and no amount of scanning your source will reveal it. Audit the dependency list for anything that renders text.
Content from a backend or CMS is not a hardcoded string at all — it is unlocalized data, and the fix belongs in the content model rather than in the front end.
The three places scanning cannot reach
Every method above operates on your React tree. Three sources of user-visible text sit outside it, and in a mature codebase they account for most of the remaining findings.
Server-rendered output — transactional email, PDF receipts, webhook payloads, push notification bodies — renders through an entirely different entry point. Nobody opens it in a browser, so nobody notices it is English. The fix is the same pseudo catalogue applied to those renderers, with an assertion on the output string rather than on a DOM.
import { renderReceipt } from '../emails/receipt';
test('receipt template is fully localized', () => {
const html = renderReceipt({ locale: 'en-XA', order: fixture });
const suspects = [...html.replace(/<[^>]+>/g, ' ').matchAll(/\b[A-Za-z]{4,}\b/g)]
.map((m) => m[0])
.filter((w) => !/^(Acme|href|span|table|style)$/.test(w));
expect(suspects).toHaveLength(0);
});
Third-party components carry their own strings. A date picker, a card-entry form, a file uploader or a rich-text editor each ships English defaults and each exposes a different mechanism for overriding them — a locale prop, a translation object, a global registration call. No scan of your own source will find them, because the text is not in your source. The reliable move is to audit the dependency list once for anything that renders human-readable text, and record in the repository which mechanism each one uses.
Backend data rendered as copy is not a hardcoded string at all. A status field arriving as "Payment failed" from an API is unlocalized data, and no amount of front-end work will fix it. The correct shape is for the API to return a stable code — payment_failed — that the client maps to a catalogue key, which also means the copy can change without a backend deploy.
Cleaning up an existing codebase
Turning the lint rule on in a codebase that has never had it produces several hundred errors, which is the point at which most teams turn it off again. The sequence that works is to make the rule a warning nowhere and an error somewhere, then grow the somewhere.
Start by scoping the rule to directories that are already clean, or that you are about to touch anyway. ESLint’s flat config makes this a two-entry array, and the second entry is what shrinks over time.
export default [
{ files: ['src/features/checkout/**/*.tsx'], rules: { 'react/jsx-no-literals': 'error' } },
{ files: ['src/**/*.tsx'], rules: { 'react/jsx-no-literals': 'warn' } },
];
Then work outward one surface at a time, moving each directory from the warning list to the error list as it is cleaned. This keeps the build green throughout, gives every cleanup a reviewable diff scoped to one team’s code, and — unlike a single enormous pull request — never blocks unrelated work.
Two things make the cleanup faster than it looks. Most findings cluster: a component with one hardcoded label usually has four, and they extract together. And the extraction itself is mechanical enough to script for the common shape — a bare JSX text node becomes a t() call plus a catalogue entry — leaving only the ambiguous cases for a human, which are typically attributes where it is not obvious whether the text is copy or a machine value.
FAQ
Should the lint rule run on test files?
No. Test files are full of literal strings by design, and lint noise there teaches the team to add blanket suppressions that then leak into application code. Scope the rule to your source directory and leave tests alone.
How do I handle strings that legitimately should not be translated?
Keep a short, explicit allowlist — brand names, currency codes, format names, single-character separators — and require a comment for any addition. The allowlist growing quickly is itself a signal worth acting on, usually that a component is rendering data as if it were copy.
Does this replace a review by a native speaker?
No. These checks answer whether text went through the catalogue, not whether the resulting translation is right. A string can be fully localized and still be wrong for the market, which is a judgement only a speaker of the language can make.
Related
- Localization Testing & Pseudolocalization — the pseudo-locale the second method depends on.
- React i18next Component Patterns — the component idioms that keep text inside the catalogue in the first place.
- Missing translation warning in react-intl — the other half of the problem: keys that exist in code but not in the catalogue.
- String Catalog Governance — where extracted strings should live once you have found them.