A fictional cross-border design consultancy with offices in Tokyo, Mexico City, and Berlin, surfaced in English, Spanish, and Japanese with locale-symmetric URLs, hreflang on every translated page, CJK + Latin typography harmonized through the Noto family, and a new shared language-switcher block that lists the current page’s translations rather than generic language links. Live at hashi.wp.philiprehberger.com.
The challenge
Clients with international audiences often hear — from agencies, from technical leads, from past experience — that “WordPress can’t really do multilingual without WPML’s $200+/yr license, and even then the URLs end up ugly and the SEO is half-broken.” Hashi argues against that. The brief: a demo that proves WordPress can deliver credible multilingual at zero plugin cost — with locale-symmetric URLs (/en/practice/, /es/servicios/, /ja/サービス/), one set of hreflang tags per translated URL, an <html lang="..."> attribute that reflects the actual page language, and a header switcher that lists this page’s translations, not a generic flag bar that strands a Japanese reader on an English page.
The approach
Polylang free is the engine. Three languages registered: en, es, ja. URL strategy is subdirectory with force_lang=1, hide_default=0 so /en/ is always prefixed (symmetry beats asymmetric /practice/ vs /es/servicios/). Locale-appropriate slugs — servicios not practice, サービス not practice — because the URL is part of the brand expression. A new shared block, sfp-blocks/language-switcher, server-renders the current queried object’s translations via pll_get_post(); languages with no translation are rendered aria-disabled with a title tooltip rather than as a phantom link that 404s. The Noto family handles the typography problem: Noto Serif Display + Inter for Latin, Noto Serif JP + Noto Sans JP for CJK. Latin-only visitors never download the CJK fonts — functions.php reads pll_current_language() and conditionally enqueues a style-cjk.css + the CJK font subset only when the current language is ja.
What’s on the site
- Five main pages × three languages = 15 page rows. Home, Studio, Practice (Servicios / サービス), Work (Trabajo / 事例), Contact (Contacto / お問い合わせ). Every URL returns 200; every translated URL emits
<link rel="alternate" hreflang="...">for the other two languages plusx-default. - Three case studies × three languages = 9 rows. A Mexico City coffee chain rebrand expanding into Tokyo, a Tokyo Modern Art Museum bilingual wayfinding system, and a Berlin bicycle manufacturer brand expansion. Each is a 200-word reconstruction of a real engagement shape — brief, approach, deliverables, sidebar with client / year / locations / roles. Nested under
/work/, so the URL is/en/work/rebranding-mexico-city-coffee-chain/↔/es/trabajo/rebranding-cadena-cafe-ciudad-de-mexico/↔/ja/事例/メキシコシティ-カフェチェーン-リブランディング/. The Japanese slug is genuine Unicode — the address bar shows it correctly, not as URL-encoded percent-escapes. - Language switcher in the header. Compact “EN · ES · JA” form, current language underlined in vermilion. Click any non-current language and you land at the translated URL of this page, not the language home.
- Language switcher in the footer. Full native-name form (“English · Español · 日本語”). Same block, different
displayStyleattribute. - Locale-aware demo banner. The platform’s
sfp-demo-bannermu-plugin is filtered per-locale — Japanese visitors see Japanese banner copy (“Hashi はフィクションです。”), Spanish visitors see Spanish (“Hashi es ficticio.”), no machine-translated mush.
The stack in detail
- Per-demo theme
hashi, child of_chassis. Indigo + paper + vermilion + sumi ink + ash palette intheme.json; four font-family slugs (sansInter /serifNoto Serif Display /sans-jpNoto Sans JP /serif-jpNoto Serif JP). Templates forfront-page,page,single-case-study,404. Header part embeds thelanguage-switcherblock; footer part too. - New shared block
sfp-blocks/language-switcher. Server-rendered via PHP. Four attributes:displayStyle(name vs code),separator,showCurrentAs(underline / bold / dim),fallbackBehavior(disabled vs language-home for untranslated pages). Readspll_the_languages()+pll_get_post(). Available to every future i18n demo on the platform. - Two mu-plugins.
hashi-banner-config.php— locale-aware demo banner copy filtered throughsfp_demo_banner_config.hashi-polylang-setup.php— exposes the translation linker helper used by the seed. - Bootstrap script.
.scripts/bootstrap-polylang.phpruns once viawp eval-fileafter Polylang activation: loadsPLL_Admin_Modeldirectly (the public API doesn’t exposeadd_language()in front-end / CLI context), registers the three languages, writes thepolylangoption with the URL strategy this demo expects. - Seed.
.scripts/seed-pages.sh, ~1700 lines. 24 page upserts, each tagged with its Polylang language viapll_set_post_language(), then linked into 8 translation groups viapll_save_post_translations(). 19 real photos resolve viaresolve_image()with ImageMagick placeholder fallback. EN home set as the WordPress static front page; Polylang routes/es/and/ja/to the translations automatically. - Conditional CJK enqueue.
functions.phphookswp_enqueue_scriptsat priority 20; readspll_current_language(); enqueues Latin fonts always, CJK fonts +style-cjk.cssonly onjapages. Verified via DevTools:/en/downloads zero CJK font bytes,/ja/downloads ~280kb of them.
Where we made tradeoffs
- Polylang free vs WPML. WPML is the obvious enterprise pick, but it’s $200+/yr and bundles features (string translation, WooCommerce multilingual, translation memory) this demo doesn’t need. Polylang free covers the routing, hreflang, and translation linking. WPML wins on shop-multilingual; Polylang wins on cost and on a thinner GraphQL surface if you later headless-extend it.
- Polylang free + static front page = asymmetric URLs. The plan asked for symmetric URLs (
/en/,/es/,/ja/) withhide_default=0. Polylang free doesn’t map the non-default-language URLs (/es/,/ja/) to the language’s static-front-page translation — only the default language gets that mapping (via WP core). Withhide_default=0all three localised home URLs returnedhome blogbody class (empty blog archive) instead of the seeded home content. Withhide_default=1,/serves the EN home directly, and/es/,/ja/301-redirect to/es/home-es/,/ja/home-ja/respectively. One redirect hop for non-default languages, but every visitor lands on the correctly-translated home content. Inner pages (/es/servicios/,/ja/サービス/, all case studies) work cleanly across all three languages without redirects. Polylang Pro’s per-languagepage_on_frontsetting would eliminate the hop; documented inbootstrap-polylang.php. - Content length per locale. ~150 words per page rather than the ~500 a real client site would carry. Short, well-translated content is more credible than long, awkwardly-translated content, and the translation pass is the load-bearing quality decision on this kind of demo — the visual system can’t paper over copy that reads like machine output.
- Polylang options not managed from a runtime mu-plugin. First version tried to enforce the
polylangoption on every pageload via aplugins_loadedhook. Polylang’s options class normalizes boolean values on write, and the runtime updates fought with that normalization —hide_defaultkept reverting. Resolution: configure Polylang once via the bootstrap script during provisioning, never from runtime code. The mu-plugin now only exposes the translation linker helper. PLL_Admin_Modelpath lookup. Polylang 3.8 relocated the admin model tosrc/admin/admin-model.php(it used to beadmin/admin-model.phpin earlier versions). The bootstrap script probes both paths so it survives the version skew.
Outcomes
- Multilingual proof in production. 24 page rows live, 21 verified URLs returning 200 across EN/ES/JA, hreflang tags emitting for every translated URL,
<html lang="ja">on Japanese pages, header switcher routing visitors to the translation of the page they’re on rather than the language home. Live at hashi.wp.philiprehberger.com. - Lighthouse: 67 / 95 / 100 / 92 (perf / a11y / best-practices / SEO). Perf is below the platform median — the CJK font conditional load lifted it from where it would have been (~55) but the 19 large source photos (~2MB each) drag LCP. A pre-processing pass to convert to WebP at 1920px max would lift perf into the high 80s without touching design; left as a documented optimisation rather than a blocker.
- One new shared block, available to every future demo.
sfp-blocks/language-switcherjoins the platform’s growing block library (now twelve blocks). Any future multilingual demo — or any existing demo that adds a second language — can drop the block into a header or footer with one block-comment. - i18n pattern documented. The
bootstrap-polylang.phpscript + the conditional CJK enqueue pattern + the locale-symmetric slug strategy are now reusable templates. The next CJK demo — or the next demo with a non-Latin script — doesn’t pay this learning curve again. The runbook in.guides/add-new-demo.mdgets an i18n appendix capturing thePLL_Admin_Modelpath probe and theredirect_langloop. - Pitch line. “Hashi is the answer when a client with an international audience says ‘WordPress can’t do multilingual properly,’ and they mean it.” The demo’s value isn’t novelty — Polylang has been around for a decade — it’s the working proof that the standard stack ships with locale-symmetric URLs, real hreflang, and a credible visual story across scripts.
