At first, I thought this would be a straightforward i18n task. Take a Korean store landing page, translate it into English, Japanese, and Simplified Chinese, then serve those versions under /en/, /ja/, and /zh-Hans/. Once I implemented it, the hard part was not calling a translation model. It was validating the result enough to call it a real public page.
Summary
- Problem: Translating only visible text was not enough when AI-generated HTML became public pages in other languages.
- Decision: Treat body text, metadata, image alt text, JSON-LD, canonical URLs,
hreflang, and the language switcher as one public surface. - Implementation: Attach
landing_localizationsto a published landing version, then send only translatable HTML segments to the model instead of the whole document. - Validation: Add locale-specific gates for leftover Hangul, leftover English nav/CTA text, and Traditional Chinese characters in
zh-Hans. - Lesson: In AI landing page localization, the hard problem was less "translation" and more "QA that decides whether this page is safe to publish."
This note follows Treating AI Copy Quality as a Grounding Problem, Not a Forbidden-Words List. That post was about grounding the original generated copy in real place data. This one is about preserving the same principle when sending that HTML to public URLs in other languages.
Other Place2Page operations notes are collected in Place2Page Engineering Notes. I plan to keep expanding that series with records that make the problem, evidence, and changed decision criteria explicit.
Verified Output
This work gave one store landing page the following locale-specific public URL shape.
https://example.place2page.com/https://example.place2page.com/en/https://example.place2page.com/ja/https://example.place2page.com/zh-Hans/
What I verified here was not "the page was indexed by search engines." The verified scope was that each public URL returned 200, had the correct locale-specific html lang, exposed a language switcher, and included hreflang alternates. Indexing and ranking are decisions made by search engines, so I did not describe them as guaranteed outcomes.
The Problem Was The Public Surface, Not Translation
The initial flow looked simple.
Korean landing HTML
-> call translation model
-> save English/Japanese/Chinese pages
-> serve them under /en, /ja, /zh-HansThat structure was too optimistic. A Place2Page landing page is not static HTML written by a person. It is generated from place data, photos, reviews, menus, opening information, and prompt output.
So the questions multiplied quickly.
- Is translating only visible text in
<body>enough? - Should
<title>and meta description be in the same language as the page? - How much of Open Graph, Twitter card, and JSON-LD fields such as
name,description, andaddressshould be translated? - Should image
alttext remain as-is? - URL, script, style, and tracking snippets must never change. Is it safe to hand the whole HTML document to a model?
- Is "looks Chinese" enough for
/zh-Hans/, or does it need to be cleaned up as Simplified Chinese?
Until those questions were answered, this was not really multilingual publishing. It was closer to "send an entire HTML document to a translation model." That felt too risky for a public page.
I Defined The Storage Boundary Before The URL Shape
The first decision was not URL shape. It was the storage boundary. Place2Page serves public pages based on the published landing version. So localization belongs to a specific landing version, not just to a project.
project
-> published landing version
-> landing_localizations
-> en
-> ja
-> zh-HansEach landing_localizations row stores project_id, landing_id, locale, rendered_html, and metadata_json. The key value here is landing_id.
If a project publishes a new landing version, old localizations are not automatically reused. Even for the same store, the HTML structure and copy may have changed. Falling back to the default page when a locale row does not exist is safer than attaching an old translation to a new page.
The public route follows the same rule.
Request
-> validate slug and locale
-> load published project and active landing
-> if a locale row exists for that landing_id, return localized HTML
-> if not, or if the locale is unsupported, redirect to the default public pageA locale URL is not just a path alias. It becomes a public surface only when that published landing version has a real localization row and the row contains the metadata required for a public response.
I Did Not Translate The Whole HTML Document
The second decision was to avoid giving the entire HTML document to the translation model.
If I send full HTML and ask the model to translate it into English, it will usually produce something plausible. But for a public page, it might modify scripts, change URLs, break attribute quotes, or turn JSON-LD into invalid JSON. An AI translation failure could break the page itself, not just produce weak copy.
So I parsed the HTML and extracted only translatable segments.
Translated targets:
- visible text
<title>- description, Open Graph, and Twitter metadata
- image
alt,title,aria-label, andplaceholder - text fields inside JSON-LD, such as
name,description,headline,caption, andaddress
Non-translated targets:
- script
- style
- URL
- image
src - structural HTML
- tracking code
- values required for map/provider integrations
The model received only segment id, kind, context, and source text. The response was JSON mapping, not HTML.
{
"translations": {
"text:0": "A quiet pasta bar near the station",
"attr:3": "Photo of the signature pasta set",
"jsonld:5": "Italian restaurant in Seoul"
}
}Instead of asking the model to rewrite HTML, I made the editable pieces explicit.
Metadata And The Normalizer Were Also Part Of The Page
At first, body text got most of my attention. But on a public page, metadata is part of the page. Users may not directly read <title> or Open Graph tags, but search results, share cards, crawlers, and structured data do.
So metadata and JSON-LD text fields also became translation targets. Structural values such as url, @context, and @type were preserved. Only natural-language fields such as names, descriptions, and addresses became segments.
Stored localized HTML was not returned as-is. Right before the public response, it went through the HTML normalizer again.
- canonical URL
hreflangalternate linksx-defaulthtml lang- favicon
- site name metadata
- visible language switcher
- public page responsive guard
For example, the default page and localized page need different canonical URLs.
<link rel="canonical" href="https://example.place2page.com/" />
<link rel="canonical" href="https://example.place2page.com/en/" />Only locales with actual rows were exposed as alternates. If a Japanese row does not exist yet, putting /ja/ in hreflang would tell crawlers about a page that is not actually available.
QA Had To Be Locale-Specific
A successful response from a translation model does not mean localization succeeded. The most dangerous failure is the kind that looks mostly fine at a glance: a few source-language or wrong-language fragments remain, while the page still feels localized overall.
So I split the quality gates by locale.
en
-> fail if translated segments still contain Hangul
ja
-> fail if kana presence is too low
-> fail if English nav, CTA, or hero copy remains
zh-Hans
-> fail if Han script presence is too low
-> fail if English nav, CTA, or hero copy remains
-> fail if common Traditional Chinese characters remain in a Simplified Chinese pageFor zh-Hans, "looks Chinese" was not enough. If Traditional Chinese characters are mixed into a Simplified Chinese URL, users will notice immediately.
鷺梁津 -> includes Traditional Chinese characters
鹭梁津 -> expected form for a Simplified Chinese pageThese checks are not perfect language-quality evaluation. They are guardrails against obvious failures that should not reach production.
Exceptions were necessary. Brand names, Instagram handles, reviewer handles, currency labels, and text around URLs can naturally remain in Latin characters. On the other hand, words such as Menu, Story, Location, Book Now, and Get Directions look like nav or CTA copy and should not remain.
The quality gate should behave like a production blocker, not a language critic.
The Language Switcher And Sitemap Followed The Same Rule
A language switcher looks like a small UI element, but on a public page it is also a crawler surface. So I inserted real <a> links inside a <nav>, not a client-side-only script.
<nav data-p2p-language-switcher="true" aria-label="Language selector">
<a href="https://example.place2page.com/" hreflang="ko-KR" lang="ko">
한국어
</a>
<a href="https://example.place2page.com/en/" hreflang="en" lang="en">
English
</a>
</nav>For users, this is a language switcher. For crawlers, it is an internal link through which locale URLs can be discovered. The current language receives aria-current="page".
The sitemap followed the same principle. It included only locale rows that actually existed for the current published landing, not every theoretically supported locale.
published landing
-> existing localization rows
-> sitemap URL entries
-> xhtml:link locale alternatesrobots.txt, sitemap.xml, canonical, hreflang, and IndexNow requests are discovery and freshness signals. They do not guarantee immediate indexing or search exposure.
Verified Scope
I separated local validation, deploy validation, and production checks.
Locally, I first passed ruff check and tests/test_landing_localization_service.py. The point was not to score translation quality. It was to make sure HTML segment extraction and locale-specific guardrails did not break.
After CI/CD passed and the production deploy completed, I checked the public URLs.
https://example.place2page.com/en/
-> 200
-> <html lang="en">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
https://example.place2page.com/ja/
-> 200
-> <html lang="ja">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
https://example.place2page.com/zh-Hans/
-> 200
-> <html lang="zh-Hans">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
-> English nav/CTA residual blocked
-> Traditional Chinese probe blocked
https://example.place2page.com/fr/
-> root fallbackAgain, this does not prove that the translation is literary or native-level. It verifies that the implementation blocks failure modes that should not be published.
Remaining Limits
This work made multilingual public pages operational, but it is not the complete product experience yet.
- Operator UI for triggering locale generation
- Per-locale generation progress and failure reasons
- An editor where a person can polish final copy
- Screenshot or visual regression QA by language
- A workflow for marking pages that need native-speaker review
Automated quality gates do not replace native review. But they prevent failures such as "an English page with leftover Korean," "a Chinese page with leftover English CTAs," and "a Simplified Chinese URL with Traditional Chinese characters" from going straight to production.
How I Will Judge This Next Time
- Treat AI localization first as public-surface validation, not as a translation-call problem.
- Check that body text, metadata, JSON-LD, canonical,
hreflang, and the language switcher all point to the same locale. - Do not give the whole HTML document to the model. Send only segments that are allowed to change.
- Do not expose a locale in URLs, switchers, sitemaps, or
hreflangunless it actually exists. - Do not mix "we sent search engine signals" with actual indexing or ranking outcomes.
- Automated QA is not native review. It is a production blocker for failures that obviously should not go public.
