DEV Community

XNOW PRO
XNOW PRO

Posted on

I shipped 174 pages across 7 languages. Two were silently broken.

Started this as an SEO task. Turned into about six hours of debugging that I want to write down before I forget the details.

The setup

A SaaS site with a service catalog. Prices come from the database and change, so the pages have to be server-rendered. A crawler hitting an empty <div id="app"> sees nothing worth indexing.

Built one renderer, wired it to seven language configs:

zh, en, id, pt, vi, ar, ru
Enter fullscreen mode Exit fullscreen mode

Chinese at the root. The rest get /en, /id, /pt, /vi, /ar, /ru. Each language: a homepage, a pricing page, and 15 platform pages. The sitemap came out around 174 URLs.

Felt good. Then I started actually checking things.

Failure 1: the hreflang cluster wasn't a cluster

I wrote hreflang tags per page, roughly:

<link rel="alternate" hreflang="zh-CN" href="https://example.com/" />
<link rel="alternate" hreflang="en" href="https://example.com/en" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
Enter fullscreen mode Exit fullscreen mode

That was the Chinese page. English had the same three lines with different hrefs. Fine.

Then I added Indonesian, Portuguese, Vietnamese, Arabic, Russian. And I appended them... to the pages I was working on at the time.

Checked the Arabic page: all seven languages listed. Checked the English page: three. The old three, untouched.

Here's what I didn't understand. hreflang isn't seven independent declarations, it's one cluster. If /en says "my alternatives are zh, en, x-default" while /id says "my alternatives are all seven plus x-default", the cluster contradicts itself. Google's documented behavior in that situation is to ignore the conflicting set — not to trust whichever page looks more complete.

So probably none of my hreflang was doing anything.

The fix was boring. Generate from one array, use it everywhere, and include the current language in its own list:

const LANGS = ['zh', 'en', 'id', 'pt', 'vi', 'ar', 'ru'];

const hreflangsFor = (path) => LANGS
  .map(l => ({ lang: LANGS_META[l].code, href: `${BASE}${LANGS_META[l].prefix}${path}` }))
  .concat([{ lang: 'x-default', href: `${BASE}${path}` }]);
Enter fullscreen mode Exit fullscreen mode

Every page calls it. Adding an eighth language later is one array entry, not six template edits.

(Yes, a page should list itself in its own hreflang set. That part confused me at first.)

Failure 2: the dir="rtl" that never made it to the page

Arabic needs RTL. I knew that going in and wrote code for it.

Then I curled the live page:

<html lang="ar">
Enter fullscreen mode Exit fullscreen mode

No dir. Arabic rendering left-to-right.

What I'd done was two separate string replacements — one for the language, one to inject dir:

let out = html.replace(/<html lang="[^"]*"/i, `<html lang="${code}"`);

if (dir === 'rtl') {
  // assumes there are other attributes to match around lang
  out = out.replace(/<html ([^>]*lang="[^"]*")/i, '<html dir="rtl" $1');
}
Enter fullscreen mode Exit fullscreen mode

The second pattern expected <html to carry attributes alongside lang. My template's opening tag was literally <html lang="zh-CN"> and nothing else. No match, replace returns the input unchanged, no error anywhere.

The output looked correct in every way I was checking — right title, right description, right canonical, right body. Just backwards text for anyone reading Arabic.

Fix: stop doing partial replacements on that tag.

const setHtmlLang = (html, code, dir = 'ltr') =>
  html.replace(/<html\b[^>]*>/i,
    () => `<html lang="${code}"${dir === 'rtl' ? ' dir="rtl"' : ''}>`);
Enter fullscreen mode Exit fullscreen mode

I left a comment above it telling future-me not to try the clever version again. I give it six months.

The CSS side, which turned out easy

Arabic is not just text direction. Lists indent from the right, table columns flip, that sort of thing.

I was ready to write a second stylesheet. Didn't need to. Logical properties handle it:

/* was margin-left: 20px */
.seo-wrap ul { margin-inline-start: 20px; }

/* was text-align: left */
.seo-table td { text-align: start; }
Enter fullscreen mode Exit fullscreen mode

These mirror themselves when dir="rtl" is set. I changed maybe five declarations and Arabic looked correct without a single [dir="rtl"] override anywhere.

Wish I'd started the project knowing that instead of learning it at 2am.

The check that actually mattered

None of the above would have surfaced from looking at the code. All of it surfaced from looking at what a crawler receives:

# what Googlebot gets back
curl -s -A "Googlebot/2.1" https://example.com/ar | grep -o '<html[^>]*>'
# <html lang="ar" dir="rtl">

# is there real text in there, or just a shell?
curl -s https://example.com/ar | sed 's/<[^>]*>/ /g' | tr -s ' ' | wc -c
# ~6100
Enter fullscreen mode Exit fullscreen mode

That second command is the one I now run on every page before calling anything done.

Because my English page passed every SEO check I had and was still an empty shell. Body text: 599 bytes. <h1> count: 0. HTTP status: 200. Title, description, canonical, hreflang, two JSON-LD blocks — all present and correct, because a prerender step was filling in <head> and then stopping.

Google had been happily indexing it as a thin page for weeks.

A 200 tells you the server responded. It tells you nothing about whether there's a page there. Strip the tags, count the characters. Under a few hundred and you've shipped a shell with good metadata.

What I'd change next time

One source of truth for hreflang, from the very first language. The copy-paste approach holds up fine for two and quietly falls apart at three.

And verify the rendered output, not the code that produces it. Both of these bugs read fine as code. Neither showed up until I fetched the actual URLs and looked at the bytes.


I'm building this for XNOW PRO, a social media growth panel — the multilingual layer covers those seven languages now. The pricing page is where the server-rendering earns its keep: real catalog prices in the HTML, no JavaScript required to see them.

Top comments (0)