English

Summary & Context

The marketing team at WIZCORE wanted to change the copy on wizcore.ai without asking a developer. The usual answer is a CMS-style admin: one form per page, one input per sentence. I suggested something else. Open the real site inside the admin, click the sentence you want to change, type, save.

  • Project: wizcore.ai, a bilingual (Korean / English) corporate site, plus its admin console.
  • Role: Full-Stack Developer.
  • Stack: Next.js (site), React + Vite (admin), NestJS + Prisma + MySQL (API).
  • Scope: 327 translation keys in two languages, across every page of the site.

Here is the result:

Hover, click, type, and the page changes under your cursor. It ends up feeling less like filling in an admin form and more like editing a Figma file πŸ˜„

The rest of this post covers why I pushed back on the form approach, the small trick that makes clicking work, and what the whole feature cost in code.

The Form Approach I Argued Against

The first draft of the requirement looked like a classic CMS screen: a sidebar of pages, and for each page a long form with a Korean and an English field for every piece of copy.

It sounds simple until you count.

  • 654 fields. 327 keys times two languages. Each one needs a label that tells a non-developer where it shows up ("Home β†’ Hero β†’ small text above the title").
  • A second map of the site. Forms have to be grouped by page and section, which means writing down the site's structure again, by hand, in the admin.
  • Drift forever. Every time a developer adds a sentence to the site, someone has to remember to add a field to the admin. Nobody remembers.
  • Bad editing experience. The editor changes a field, saves, opens the site in another tab, scrolls to find the sentence, and checks whether it now wraps onto three lines. Then does it again.
  • Refactoring the site. Components that read strings from translation files would have to be rewired to read from the CMS, or the two sources merged somewhere.

My rough estimate was thousands of lines of form definitions, grouping config and glue code, most of which would need maintenance on every content change. And the result would still be worse to use than looking at the page.

The editors already know where the text is. It is on the page. So the admin should let them point at it.

The Idea: The Site Is the Editor

The site already had every string behind an i18n key:

<p className="eyebrow">{t('snap.hero.eyebrow')}</p>

If the admin can find out which key produced the text under the mouse, everything else is ordinary: show a dialog, save { key, ko, en } to the database, and have the site lay those overrides on top of its translation files.

So the whole problem comes down to one question: given a clicked piece of text, what is its key?

The Hard Part: Getting the Key Back from the Text

Two things make the obvious answers fail.

  1. The admin and the site are on different origins. The admin loads the site in an iframe, and the browser will not let it read that iframe's DOM.
  2. A lot of copy never touches a JSX element directly. Strings are passed through props like head={{ title: t('...') }} and rendered deep inside shared components. There is no single place to add a data-key attribute without touching hundreds of call sites.

The fix was to put the key inside the text itself, in characters nobody can see.

Unicode has a block of tag characters (U+E0020–U+E007E). They are default-ignorable: browsers render them with no width and no line breaks. They also map one-to-one onto printable ASCII, so encoding a key is a single addition:

const TagBase = 0xe0000;

export function encodeKey(key: string): string {
    let marker = '';

    for (const char of key) {
        marker += String.fromCodePoint(TagBase + char.codePointAt(0)!);
    }

    return marker;
}

i18next has post-processors, which run on every translated value. Adding one appends the invisible key to every string on the site. This is the core of the entire feature:

const editMarkers: PostProcessorModule = {
    type: 'postProcessor',
    name: 'wzEditMarkers',
    process(value, key) {
        return `${value}${encodeKey(Array.isArray(key) ? key[0] : key)}`;
    },
};

Not one component had to change. "Design-to-Manufacturing AI" now carries snap.hero.eyebrow along with it, invisibly, wherever it ends up in the DOM.

The markers are only turned on in edit mode (?wz-edit=1), and only after the page mounts. The server render never includes them, so hydration does not break and normal visitors never receive them.

Pointing at Text

Inside the iframe, a small client component called EditBridge listens to the mouse. It asks the browser for the text node under the cursor, not the element, because one element can hold several strings:

function textNodeAt(x: number, y: number): Text | null {
    const node =
        document.caretPositionFromPoint?.(x, y)?.offsetNode ??
        document.caretRangeFromPoint?.(x, y)?.startContainer ??
        null;

    return node?.nodeType === Node.TEXT_NODE ? (node as Text) : null;
}

From that node it decodes the tag characters back into a key, draws a highlight box over the text's bounding rect, and on click sends a message to the admin:

window.parent.postMessage(
    {
        source: 'wz-site',
        type: 'select',
        key,
        current: { ko: i18n.getResource('ko', 'translation', key), en: i18n.getResource('en', 'translation', key) },
        fallback: { ko: shippedText(key, 'ko'), en: shippedText(key, 'en') },
    },
    adminOrigin
);

A few details that matter for the experience:

  • Click is captured before links and buttons react, so clicking a button label edits the label instead of navigating away.
  • Holding ⌘ / Ctrl / Alt while clicking follows the link instead, so editors can still move around the site inside the frame.
  • Edit mode sticks to the tab through sessionStorage, so it survives client-side navigation that drops the query string.
  • Only the admin origin is trusted in either direction, and the site's CSP allows framing only by frame-ancestors 'self' <admin origin>.

Live Preview Without Saving

The admin talks back over the same channel. While the editor types, the dialog posts an apply message (debounced by 200 ms). The site writes the value into its i18n resources and asks react-i18next to repaint:

i18n.addResource('ko', 'translation', message.key, message.ko);
i18n.addResource('en', 'translation', message.key, message.en);
i18n.emit('languageChanged', i18n.language);

The editor sees the new sentence in its real layout, at its real font size, before saving. If a heading suddenly wraps onto three lines, they see it right away. Cancel sends the previous value back. "Revert to default" restores the text that shipped with the build.

Saving and Serving

The backend is intentionally boring. One table stores only the strings that were actually changed:

CREATE TABLE `SiteTexts` (
    `id` CHAR(36) NOT NULL,
    `key` VARCHAR(255) NOT NULL,
    `ko` TEXT NOT NULL,
    `en` TEXT NOT NULL,
    UNIQUE INDEX `SiteTexts_key_key`(`key`),
    PRIMARY KEY (`id`)
);

There are three admin endpoints (list, upsert, reset) and one public list endpoint. On each request the site loads the overrides once (wrapped in React's cache) and applies them over the shipped translation files. Keys that do not exist in those files are ignored, so the database can never add text the site does not know about. A save triggers on-demand revalidation, so the change goes live without a redeploy.

What It Cost

PartLines
Key encoding and decoding (edit-mode.ts)74
i18next post-processor~10
Site bridge: hover, click, messages (edit-bridge.tsx)258
Admin: editor page and edit dialog~460
API: CRUD module and migration~350
Code per editable sentence0

The trick that makes the whole site clickable is under 100 lines. The rest is an ordinary iframe page, a dialog and a four-endpoint CRUD module, most of it the same boilerplate any admin feature needs.

More important is the part that does not grow. A new sentence added to the site is editable the moment it ships, with no admin changes, no field definitions and no page map to update. The form-based version would get bigger with every piece of copy. This one stays the same size.

How the Team Reacted

The best feedback came from the people who would actually use it. I go by Adam at the office, so that is the name in these messages.

Slack message from a coworker in Korean reading: ν…μŠ€νŠΈ νŽΈμ§‘ν•˜λŠ”κ±° λ˜κ²Œμ‹ κΈ°ν•˜λ„€ 이거

"This text editing thing is really cool."

Two Slack messages in Korean from coworkers asking to bring the string editor to other domains and site admins, with thumbs-up reactions

"@Adam, it would be great to apply this string editor to our other domains later lol. It's just so convenient."

"I loved it when Adam showed it yesterday..! If we can bring it to the www and other site admins too, I don't think basic site management will need our hands anymore!"

The second message is the real win. The goal was never a nicer form. It was making routine copy changes something nobody has to be pulled in for.

Takeaways

  • Question the shape of the requirement, not only the implementation. "Build a form for every text" was a solution, not the need. The need was "let non-developers change copy safely."
  • Reuse what the system already knows. The i18n keys were already a complete, maintained index of every string on the site. The editor just needed a way to read it from the screen.
  • The best editor for a page is the page. Editing in place gives context, live layout feedback and zero hunting, which no form can match.
  • Keep the storage dumb. Store only overrides, validate keys against what shipped, and fall back to the build's own text.

If you have a site that already runs on i18n keys, you are probably closer to inline editing than you think.

0
0
0
0