Skip to main content

Devlog · Phase 0 · 7/7

Multilingual SEO and GEO: 6 Locales, SSR, and AI Extraction

Six indexable URLs per language, bidirectional BCP47 hreflang, an @id-linked JSON-LD graph, and an llms.txt: how aubia.dev makes itself readable to engines and AIs alike.

Published on 24 August 20265 min read
  • seo
  • i18n
  • geo
  • inertia

A pre-launch marketing site has one job on the visibility side: to be findable, in several languages, by the classic engines and by the generative engines that now answer in place of the ten blue links. aubia.dev is served in six languages, server-rendered, with markup designed for extraction. This last article in the series takes that machinery apart. And the blog you're reading runs on the same rules.

Six Locales, One URL Per Language

The site is routed under a locale prefix: /fr, /en, /es, /de, /it, /pt. Each language therefore has its own indexable URL, never a parameter or a cookie that would hide the content from crawlers. A middleware resolves the locale before routing, from the URL segment, the cookie, then the Accept-Language header. The root / redirects to the best detected language.

For the international audience outside covered regions, x-default points to English. Rendering is done in SSR through Inertia: the full HTML goes out at the first byte, which is a non-negotiable invariant for SEO. An engine visiting a page receives its rendered content, not an empty shell waiting for JavaScript hydration.

These six languages don't all hold the same status, and I own the hierarchy. French is my editorial source, written by hand. English is a manually translated pivot, because it's the language of my international audience and it doesn't tolerate approximation. Spanish, Italian, German, and Portuguese are then machine-translated from that English pivot. And the most critical copy, the hero title, the claim, the key arguments, stays excluded from machine translation and always goes through my hand.

Behind these six languages, there's no third-party i18n library. The mainstream npm solution turned out to be incompatible with React 19 and risked leaking state between requests under Octane's long-running server. So the i18n engine fits in a homemade hook of about fifty lines, wired to Inertia's native prop sharing, without a single added dependency.

Hreflang, Both Ways

Hreflang tells an engine: this page also exists in these other languages, at these precise URLs. The rule that makes most implementations fail is bidirectionality: each version must reference itself and reference all the others. I use full BCP47 codes, more precise than a short code, to lift the country ambiguity between pt-PT and pt-BR.

The dynamic sitemap produces this mesh for every published page, with one alternate link per locale:

foreach (self::LOCALE_TO_BCP47 as $altLocale => $bcp47) {
    $altLoc = $baseUrl . '/' . $altLocale . $path;
    $xml .= '<xhtml:link rel="alternate" hreflang="' . $bcp47 . '" href="' . $altLoc . '"/>';
}

On the page side, the SeoHead component lays down the same signals in the <head>, but only on indexable pages. Ephemeral pages, like the waitlist confirmation, are noindex: adding a canonical or an hreflang there would send a contradictory signal.

A JSON-LD Graph Linked by @id

Aubia's structured data forms a single @graph, where each entity references itself by a stable @id: the Organization, the WebSite, the SoftwareApplication, and the founding Person point to one another. This wiring helps engines and search AIs understand that these objects describe the same entity, and not four unrelated things.

const jsonLd = JSON.stringify({
    '@context': 'https://schema.org',
    '@graph': [
        { '@type': 'Organization', '@id': `${SITE_URL}/#organization`, /* ... */ },
        { '@type': 'Person', '@id': `${SITE_URL}/#founder`, /* ... */ },
        { '@type': 'WebSite', '@id': `${SITE_URL}/#website`, /* ... */ },
        ...additionalJsonLd,
    ],
});

The extension point is additionalJsonLd: each page injects its own nodes into the shared graph. The landing adds its FAQPage block. A blog article adds its BlogPosting and its breadcrumb, without touching the Organization/WebSite base.

The animated JSON-LD graph: WebSite, Person and SoftwareApplication linked to Organization through their @id, with FAQPage and BlogPosting injected per page via additionalJsonLd.

GEO: Writing for the Engines That Read

I wrote these pages to be quoted, not just ranked: when an AI answers in place of the ten blue links, what matters is that it can extract a whole passage without distorting it. Two concrete levers on aubia.dev.

The first is a public/llms.txt file at the root: a structured summary of the product, its positioning, and its status, written to be read by a machine. It states what Aubia is, its cross-review difference, its audience, and its roadmap, in plain text with no interface noise.

The second is a writing rule, applied to this very article: self-sufficient sections. Each H2 heading opens on a definition, then develops into a block of about a hundred and fifty words that stands on its own, out of the page's context. An engine that slices the page into excerpts must be able to quote any block without the meaning collapsing. Short lists and explicit headings finish the job.

The Meta Loop: This Blog Applies Its Own Rules

This blog is itself the demonstration of everything above. It's flat-file: each article is a Markdown file in the repo, converted to HTML at parse time, with no database, faithful to Phase 0's stance. Publishing an article means merging a branch, nothing more.

Publication is scheduled by the date in the front matter: the module keeps every article in cache, but at request time serves only the ones whose date has passed. An article dated on a Monday shows up that Monday, without a redeploy, and stays invisible before: absent from the index, the RSS feed, the sitemap, and a 404 on its URL.

Each published article earns its sitemap entry with its true last-modified date, its language alternates, its BlogPosting markup, and its breadcrumb. The same machinery of six locales, bidirectional hreflang, and JSON-LD graph that I described for the landing applies, article by article, to these pages.

This was the final installment in the series on building Phase 0. I built all of Phase 0 at this level of detail, and your feedback will decide what comes next. The waitlist is the place to follow it closely.

This build is chronicled here, article after article. Come aboard: your feedback will shape what comes next.

Join the waitlist