UI copy stays consistent when every user-visible string lives in one catalog in the repo, is referenced by key, and changes only through a reviewed diff. That is the same discipline you already apply to logic, applied to wording. Everything else on this page is the practice that makes it stick.

This is the operational guide. The architecture behind it, and the argument for why the codebase should be the source of truth for product copy, is in code-native product copy. Read that if you want the why. Read on if you want the how.

Where should UI copy live?

In one catalog file per source language, inside the repository, next to the code that renders it. Not in a spreadsheet, not in a design file, not in a wiki page that was accurate in March. The catalog is the place a developer, a reviewer, a translator and a coding agent all look when they want to know what a screen says.

The practical rules that follow from that:

  • One catalog, one source locale. A single en.json (or .po, .ftl, .strings, whatever your i18n library reads) is the copy of record. If you have several apps in a monorepo, each app gets one, and shared strings get a shared catalog that both import. What you avoid is two files that can hold the same message.
  • Group by screen or feature, not by component. Keys such as checkout.summary.total age better than SummaryCard.totalLabel, because components get renamed and split while the screen and the message stay put.
  • Name the key for the message, not the wording. checkout.submit survives a change from "Place order" to "Buy now". checkout.placeOrderButton does not, and the mismatch between key and text is how a reviewer later assumes they are two different strings.
  • Keep the catalog in the same pull request as the code that uses it. A string added in one PR and its component in another is a string that will be missed, forgotten or duplicated.

If your strings are still literals inside components, the catalog is the first thing to create. Doing that by hand is a project in itself, which is the case for connecting the repository and letting the conversion do it, covered further down.

Why does referencing by key beat searching for the literal?

Because a key can only resolve to one string, and a literal can be typed in as many ways as there are developers. "Sign in", "Sign In", "Log in" and "Login" are four strings to a search tool and one intent to a user. When every component calls t("auth.signIn"), the question of which wording is correct is answered once, in one place, and the answer applies everywhere the key is used.

Two habits keep this working:

  • Reuse before you add. Before creating a key, search the catalog for the message. If common.cancel exists, a new dialog.cancelButton with the same text is the start of drift, not a harmless duplicate. The two will be edited independently the moment someone changes one of them.
  • Do not concatenate. t("cart.items") + " " + count produces text that no catalog line describes. Use your library's interpolation and plural forms so the whole sentence is one reviewable unit.

The exception people reach for is "this string only appears once, so a literal is fine". It appears once today. The component will be copied, the screen will get a sibling, and the literal will travel with it and then diverge.

How do you review copy changes in a pull request?

Treat a changed catalog line the way you treat a changed function signature: something a reviewer reads, questions and approves. This is the practice that turns consistency from a memory problem into a review problem, and review is the one moment someone is reliably paying attention.

What makes catalog review work:

  • A wording change is its own line in the diff. That only holds if the string is in the catalog. A wording change inside a component is a diff line too, but it hides among logic changes and is skimmed past as "just text".
  • Route catalog changes to a copy owner. A CODEOWNERS entry on the catalog path means that whoever owns the wording, whether a product person, a content designer or the developer who cares most, is requested on every change to it. Everything else in the PR goes through normal review.
  • Ask three questions per changed line. Is this the approved term for this thing? Does it match the strings around it in tone and casing? Does the key name still describe the message? A reviewer who asks those three catches most of what a style guide exists to prevent.
  • Reject silent renames. A PR that changes auth.signIn from "Sign in" to "Log in" with no explanation is a copy decision made by whoever happened to be editing. It needs a sentence in the PR description saying why, or it needs to be reverted.

The failure mode this prevents has a name. Copy drift is user-visible text diverging from its approved source across surfaces and over time, with no single source of truth to diverge from. What is copy drift covers the causes; catalog review in the diff is the practice that stops most of them.

How do you stop new hardcoded strings from getting in?

Make a new literal fail the build. Rules and reviews catch what people remember to look for. A lint rule catches the rest, on every pull request, without anyone having to remember.

The setup is small:

  • Enable a no-literal-string rule. eslint-plugin-i18next ships one for JavaScript and TypeScript projects. Scope it to JSX text and to user-facing attributes such as input hints, title, aria-label and alt, and mark test files and Storybook stories as exempt so the rule stays credible.
  • Run it where merges happen. A pre-commit hook is convenient; a CI step is the one that counts, because it runs on the pull request whether or not the author's editor was configured.
  • Add a check for unused and missing keys. Most i18n libraries have a companion tool that lists keys referenced in code but absent from the catalog, and keys in the catalog referenced nowhere. Run it in CI too. Unused keys are where stale wording hides.
  • Start strict, then allow exceptions by comment. A rule that is disabled for the whole components/ folder is not a rule. A rule that is disabled on one line with a reason is documentation.

Teams building with AI coding tools hit this harder, because the tools regenerate components from local context and bring literals back with them. Why Cursor AI keeps adding hardcoded strings goes through the lint rule in detail, including the three-layer setup of rules file, linter and CI.

What belongs in a copy style guide for developers?

A short file in the repository that fits on one screen, states the voice, fixes the terms, and says where strings go. Long style guides live in a wiki and are read once. A short one lives next to the code and is read by people and tools every time a string is added.

A working COPY.md (or a section in your existing contributor guide) covers:

  1. Voice, in three sentences. Who the product sounds like, how formal it is, whether it says "you" or "the user". Enough to settle most arguments.
  2. Terms that never vary. The product's name for each feature, the verbs for the core actions (is it "Save" or "Update"? "Delete" or "Remove"?), and the words you have decided not to use.
  3. Casing and punctuation rules. Sentence case or title case in buttons and headings, full stops in tooltips or not, how numbers and dates are written.
  4. Where strings go and how keys are named. The catalog path, the grouping rule, the key-naming rule, and "search before you add".
  5. What to do when unsure. Who to ask, or which key to reuse in the meantime.

Keep it in the repository so a rules file can point at it, a reviewer can link to it in a comment, and a coding agent can read it before it writes a label.

How do you keep AI coding agents from breaking consistency?

Give the agent a source of truth to read and a gate that catches it when it does not. An agent generating a form will happily write "Submit" on one screen and "Send" on the next, because each was the locally likely word. It is not being careless; it has nothing to be consistent with.

Two moves fix most of it. First, a rules file (AGENTS.md, .cursor/rules, CLAUDE.md, whichever your tools read) that says: user-visible text goes in the catalog, reuse existing keys, and read COPY.md before writing a label. Second, the lint rule from the section above, because a rules file reduces guessing and a lint rule catches the guesses that got through.

That is the whole of what this page has to say about agents. The specific problem of an agent rewording strings that already existed, and how to make that edit visible instead of silent, is its own topic: how to stop AI coding agents rewriting your UI copy.

Where does localization fit?

Consistency and localization depend on the same thing, an agreed source, so the catalog that gives you one gives you the other. A team that cannot say which of three phrasings is the approved one cannot translate any of them correctly either. Fix the source and the translation becomes a derived artifact of the catalog rather than a separate project with its own copy of the truth.

In practice that means the catalog you built for consistency is already the file a translator or a translation tool works from. Each key gets its equivalents in every target language, the same key names apply, and a wording change in the source catalog is visible as a change that the translations need to follow. When those translations are left to drift behind the source, you get the class of problem described in why translation files fall out of sync, and the fix is the same discipline again: one catalog, keys, changes in the diff.

Where globalize.now fits

globalize.now is a code-native copy management system with localization built in, and every practice on this page is what it assumes about your repository. You connect a GitHub or GitLab repository in the app. A scan works out what is there. If the strings are literals inside components, a one-time conversion moves them behind keys into a catalog and adds the i18n setup if there is none. If you already use next-intl, react-i18next, i18next or Lingui, it works with what you have. The result arrives as a pull request on its own branch, so the first catalog goes through exactly the review described above. After that, new source strings you add are translated by push jobs that open a pull request with the result. The repository stays the only source of truth.

We are building toward the same discipline holding across the marketing site, the landing page and the product from one source, so that a feature is described the same way everywhere a user meets it. That is where we are heading, not something to buy today. Pricing is on the pricing page.

A checklist you can adopt this week

  1. Create one catalog per source language, in the repository, and decide the key-naming rule.
  2. Move the strings from the screens you touch most into it; reference by key.
  3. Turn on a no-literal-string lint rule, scoped to user-facing text, and run it in CI.
  4. Add a CODEOWNERS line on the catalog path.
  5. Write COPY.md: voice, fixed terms, casing rules, where strings go. One screen.
  6. Point your agent rules file at the catalog and at COPY.md.
  7. Reject any wording change in review that has no reason in the PR description.

If the second step is the one that looks like a month of work, connect a repository, read the plan it proposes, and review the pull request it opens.

globalize.now turns hardcoded app copy into translation-ready locale files and keeps them updated as you ship.

Try globalize.now free