Skip to content

Retired Terms Must Stay Retired After a Rebrand

Adityo Guni Waluyo

A rebrand is a per-surface decision list: five string surfaces, two entries that must not change, and a legacy-guard test that keeps retired terms retired.

TL;DR

Renaming Duta Muda and Jelajah Kota to PESONA meant mapping five surfaces—copy, SEO metadata, admin labels, wordmark, and database rows—instead of a blind find-and-replace. Exceptions like contact emails, a deferred site title, and archived documents stayed untouched. A legacy-guard test with negative assertions keeps retired terms from creeping back in.

The directive landed as three corrections in a single chat thread. The first line said "Duta Muda" was retired. The second said "Jelajah Kota" went with it. The third named the replacement: PESONA, all caps, no alternative spelling. Three messages, one city government portal, and a diff that had to touch every user-facing surface without breaking a single feature.

The obvious reading is that a rebrand is find-and-replace: open the editor, search the two retired terms, swap in PESONA, ship before lunch. That reading is wrong in a specific way: find-and-replace only sees source strings. A rename directive applies to surfaces the search box never reaches, and to strings that must deliberately not change.

The work turned out to be a per-surface decision list. Rendered copy, SEO metadata, admin placement labels, the wordmark text, and site settings stored in the database each carry the old terms through a different channel, and each channel has its own safe-update rule. The string swap is the chore. Deciding what changes and what stays is the work.

Map every surface before the first edit

Enumerate the consumers before the files. Five surfaces showed up in this case.

Rendered copy is the easy one: page text, headings, footer labels. SEO metadata is easier to miss because it lives in code, not in a CMS. In Next.js, static metadata is defined by exporting a Metadata object from a layout or page file [2], which means title templates, description strings, and JSON-LD schema values are compiled artifacts of the repository.

A rename sweep that stops at page copy leaves the old names in every search result and link preview. For this portal, the SEO test trio (metadata.test.ts, schema.test.ts, and site.test.ts) had to move together with the source constant file, since the tests assert those strings.

Admin placement labels are rendered copy too, but only admins see them; they still carry branding into internal screenshots. The wordmark is text in the DOM, not an image, so it is a string like any other. And the database holds site settings: rows where the old terms were typed into content columns rather than into code.

For those rows, MySQL's REPLACE(str, from_str, to_str) "Returns the string str with all occurrences of the string from_str replaced by the string to_str", performs a case-sensitive match, and "This function is multibyte safe." [5] Multibyte safety matters on utf8mb4 content columns, where a byte-wise replacement risks splitting a character. Case sensitivity means running the query once per casing variant the content contains:

UPDATE site_settings
   SET value = REPLACE(value, 'Duta Muda', 'PESONA')
 WHERE value LIKE '%Duta Muda%';

Lowercase variants go untouched by that statement, so run a separate check for casing variants before closing the task.

Then list what does not change. Two entries belong there, the part a blind find-and-replace would destroy. First, the contact email. It is an identifier that reaches real inboxes; it is not branding, and rewriting the local part would bounce every message sent after the switch. Second, one site_title phrase is deferred pending an owner decision, so it keeps its current wording until that decision lands. Write the exceptions down before the sweep starts, not discover them in review.

Historical documents keep the retired names. They record what the portal was called at the time; rewriting history turns old incident notes into forgeries. The living knowledge docs (the header note and the design-doc addendum) update to the new scheme, while archived material stays untouched.

The whole change came out as a copy-only diff: 51 files, plus 101 and minus 98 lines. That classification matters downstream: a copy-only diff keeps the security-review scope honest, and it is only possible when the scope list separated strings from behavior from the start.

Assert the absence, not the presence

A grep confirms today's sweep. It guards nothing tomorrow. The next pull request can reintroduce "Duta Muda" in a new component, and no reviewer will notice unless something fails.

The legacy-guard test fixes that. The setup is Vitest 4 with Testing Library for React under jsdom; Vitest provides chai assertions by default plus Jest-compatible assertions built on top of chai [3]. Testing Library's queryBy* queries return the matching node, or null if no elements match, which the docs describe as useful for asserting an element that is not present [1]; jest-dom's toBeInTheDocument asserts whether an element is present in the document, and its own examples show the canonical negative form .not.toBeInTheDocument() [4].

The form on this project, with fictional names:

// footer.test.tsx
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { expect, test } from "vitest";
import { Footer } from "./footer";

test("retired terms do not return in the footer", () => {
  render(<Footer />);

  expect(screen.queryByText("Tentang Duta Muda")).not.toBeInTheDocument();
  expect(screen.queryByText("Jelajah Kota")).not.toBeInTheDocument();
  expect(screen.getByText("Tentang PESONA")).toBeInTheDocument();
});

The negative assertions do the guarding; the final positive assertion proves the replacement is actually rendered, so the test cannot pass on an empty footer. Note the queryByText for labels that must be gone: getByText on a missing element throws a descriptive error ("Unable to find an element with the text"), and the negative assertion never reads as a statement about retired terms. The most misleading symptom: the test fails with a search message, not with a notice that an old term returned.

Run the guard before and after the sweep

Order matters. Run the guard first while the old terms are still present; the negative assertions must fail then, proving the test can detect the problem. After the sweep, run the file in isolation so the result is quick to read. The comments below show the expected output pattern:

npx vitest run footer.test.tsx
# Expected output pattern:
#  ✓ footer.test.tsx (3 tests) 8ms
#  Test Files  1 passed (1)
#       Tests  3 passed (3)

A silent run with zero tests found is not success; it means the file matched nothing. If the guard passes both before and after the sweep, the assertions are probing labels that never rendered. If a contributor later reintroduces the "Tentang Duta Muda" label, the first assertion fails and the message shows the element that should not exist.

The diff was small. The scope list made it small; the guard test keeps it small a month from now. The deferred site_title phrase is still waiting on its decision, and that is the correct state: a rename is a decision list with visible open items, not a search box emptied twice.

Sources

  1. Testing Library, About Queries
  2. Next.js, generateMetadata
  3. Vitest, expect
  4. Testing Library, jest-dom
  5. MySQL 8.0 Reference Manual 14.8, String Functions and Operators (as archived 2026-10-10)

Related articles