Devlog · Phase 0 · 7/7
aubia.dev's Multilingual SEO: URLs, hreflang and JSON-LD
Each of aubia.dev's six languages has its own URLs and hreflang links. The served HTML also links articles to their author, without guaranteeing indexing or citations by AI.
- seo
- i18n
- geo
- inertia

A visit to /fr/blog/multilingual-seo-geo must return this article in French, even if the browser prefers English. The language cookie must not change the content at that URL either. To share a translation or have it indexed, I need its address to always identify the same language.
The public pages on aubia.dev use six prefixes: /fr, /en, /es, /de, /it and /pt. Slugs stay in English across all versions. The prefix is enough to distinguish them.
Inertia's server-side rendering provides the content and metadata in the initial HTML response. A crawler can read them without executing the page's JavaScript. This depends on SSR working: if it fails, the site's configuration allows a fallback to client-side rendering. The content then depends on JavaScript, as the article on this stack explains in detail.
The URL's Language and the Browser's Language
The SetLocale middleware selects the language before rendering. It checks the URL segment, then the preference cookie, then the browser's Accept-Language header, and finally the application configuration. It uses the first recognized value.
On a page under /fr, the URL segment therefore always takes precedence. On /, there is no language segment: the cookie, the browser's preferences and the default language, English, determine the redirect destination.
To read Accept-Language, the server goes through the languages in preference order and compares their first two letters against the six supported languages. Without a cookie taking precedence, a browser requesting pt-BR can therefore be redirected to /pt.
The root responds with a 302 redirect. A 301 would indicate a permanent move, whereas the destination depends on the visitor and their current preference.
A Response That Varies by Request
A shared cache must not reuse a redirect to French for a visitor requesting Italian. The Vary header identifies which request headers distinguish the responses.
Inertia sets Vary: X-Inertia to separate its HTML and JSON responses. Its middleware replaces this header, so SetLocale adds the language-related values after that middleware returns, only on the redirect route:
if ($request->routeIs('home.redirect')) {
$response->headers->set('Vary', ['Cookie', 'Accept-Language'], replace: false);
}
The response then includes X-Inertia, Cookie and Accept-Language. Localized pages retain Inertia's Vary, with no additions for language negotiation: their URL already determines the language.
This mechanism depends on the cache receiving the response. Cloudflare does not honor all Vary values by default. Sending the header alone therefore does not prove that the CDN distinguishes these variants; its cache rules must be compatible with the responses served.
Hreflang Connects Translations
A separate URL for each language makes each version independently accessible. The hreflang annotation tells search engines which pages are translations of one another.
Each version lists its own URL and those of the other available languages. Google requires these reciprocal links: if two pages do not reference each other, the annotations for that pair may be ignored. Pairs that do link back to each other can still be processed.
I use language codes alone: fr, en, es, de, it and pt. The site offers one Portuguese version for Portuguese speakers, rather than separate content for Portugal and Brazil. pt describes that choice; pt-PT would indicate Portuguese content intended for Portugal.
The annotation informs the search engine about the intended audience. The root's HTTP redirect chooses a visit's destination based on the request received.
A Fallback Version
The x-default annotation identifies a fallback version for languages or regions not covered. On aubia.dev, it points to English. For an article whose English translation is not published, it is omitted: a URL returning 404 would not make a useful fallback destination.
The blog's annotations are limited to published translations of the same slug. An article available in three languages lists those three versions, even though the rest of the site supports six.
The SeoHead component writes these links into the <head>. The sitemap also generates them from the published languages. The two methods are equivalent for Google; using both adds no search ranking benefit. Maintaining both mainly means keeping their destinations consistent.
A Canonical URL for Each Language
The canonical link answers a different question: which URL should be preferred among identical or very similar pages? A complete translation is not a duplicate of the original simply because it covers the same subject in another language.
Each version of an article therefore declares its own canonical URL. The French page does not designate the English page as canonical. Google recommends a target in the same language when one exists, and retains the final decision on which URL to select.
The confirmation and unsubscribe pages use noindex,nofollow. The site adds neither canonical nor hreflang links to them and excludes them from the sitemap. This keeps language-version annotations on pages intended for indexing.
Open Graph uses a different format: og:locale is fr_FR, for example. The Open Graph specification defines language_TERRITORY for this optional property. That territory-based format does not transfer to hreflang targeting.
Language Links Available Before JavaScript
The language switcher contains real links to the page's other versions. Google recommends these links alongside hreflang annotations so visitors can choose for themselves.
The component uses the native HTML elements details and summary. The anchors are rendered even when the panel is closed. With SSR, they are therefore present in the received HTML, without waiting for a click to add them to the document.
The browser handles opening with a mouse or keyboard. A React effect adds closing on Escape and on an outside click. A menu that only mounts its content when opened would not provide these links in the initial render; that limitation depends on the component's behavior, not merely on the presence of a React portal.
The destinations come from the server. On an article, they are restricted to published translations, just like hreflang. The switcher does not offer a language in which the article is missing.
An Identified Author in the JSON-LD Graph
The SeoHead component describes three shared entities in JSON-LD: the Aubia organization, me as a person and the website. JSON-LD uses the schema.org vocabulary here to name types and relationships.
Each entity has a stable @id. References to these identifiers declare a relationship without repeating the entire object. The graph includes the following links, shown in an excerpt limited to types, identifiers and relationships:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://aubia.dev/#organization",
"founder": { "@id": "https://aubia.dev/#founder" }
},
{
"@type": "Person",
"@id": "https://aubia.dev/#founder",
"worksFor": { "@id": "https://aubia.dev/#organization" }
},
{
"@type": "WebSite",
"@id": "https://aubia.dev/#website",
"publisher": { "@id": "https://aubia.dev/#organization" }
}
]
}
Pages add their data through additionalJsonLd. The home page supplies a FAQPage with the visible questions and answers. An article adds a BlogPosting and a BreadcrumbList, its breadcrumb trail.
In the BlogPosting, author references #founder and publisher references #organization. The article's author is therefore declared to be the same person as the site's founder. Adding an object to @graph does not, by itself, create a relationship with every other object: the properties and their references describe those relationships.
Each article's visible byline links to the same public profile as the Person node. Open Graph metadata declares the article type and its publication and modification dates.
The graph declares neither SoftwareApplication nor a preorder offer: the site provides a waitlist, not a software download. No reviews or ratings are invented to qualify for a rich result. The visible FAQ retains its markup, identified by the localized page URL, but Google has stopped showing FAQ rich results since May 7, 2026. These declarations guarantee neither their use by a search engine nor indexing.
GEO, With No Guarantee of Citation
The term GEO, for Generative Engine Optimization, refers to practices intended to improve content visibility in AI-generated answers. It is not a shared protocol that ensures a page will be read, selected or cited.
To appear as a supporting link in Google Search's AI Overviews or AI Mode, a page must be indexed by Google and eligible to appear with a snippet. No special schema.org markup is required. Even a page that meets these conditions has no guarantee of appearing as a source.
I try to write sections that make sense without having to reread the whole article. That helps readers follow an explanation and quote a passage, without guaranteeing that an AI will reproduce it faithfully.
An llms.txt File to Maintain
The site also serves an llms.txt file. It contains a product overview in Markdown and links to public pages, with no interface to navigate. It draws on the llms.txt proposal, which is separate from the crawl rules in robots.txt.
This file is written separately from the site and its articles. Its claims and links therefore need checking when they change elsewhere. Its presence proves neither that an engine reads it nor that it improves the site's rankings or citations.
Publishing Translations
A blog article is a Markdown file for each language. Its publication date is read from the front matter, in UTC. The build compiles Markdown and Phiki highlighting into a local catalogue required in production, including future articles. Dates are checked on every read: before the date, the URL returns 404; once it is reached, the article becomes accessible without another deployment. A missing, invalid or stale catalogue produces a 503 response on routes that depend on it, without running Phiki during the request.
The index lists it, and the RSS feed publishes its summary with its publication date. The sitemap uses its updated date, or its publication date if none is provided, for lastmod. It adds hreflang annotations for the available translations. The page itself supplies the BlogPosting and breadcrumb trail.
The RSS feed and sitemap are served without sessions or cookies and specify an HTTP cache lifetime of one hour. Their refresh in an intermediate cache or a feed reader may therefore lag behind the article becoming available in the application. Each blog index's lastmod uses the latest modification or publication date among its published articles. Static pages do not declare an unknown date. Blog pages also include a discovery link to the localized RSS feed in their <head>.
Interface Text Follows a Different Process
Interface text is stored in lang/<locale>.json files. French is the source, with English as the pivot for Spanish, German, Italian and Portuguese.
When a change to the French file is committed, the hook sends the translation agent the added or modified keys that are not excluded. It merges the returned values into the English file and preserves the others. Key deletions are applied separately. Propagation from English to the four other languages is triggered by a manual command.
Three prefixes are excluded from automatic additions and changes: usp.*, features.tagline.* and letter.body.*. The first two have no keys in the current dictionaries. The third protects the body of the Creator's letter. The claim and the home page's hero banner title are not among these exclusions, which makes them eligible for automatic translation.
The server loads translations for the requested language and fills in missing keys with English. The React hook useT() then reads the values shared in Inertia props. Articles do not go through this script: their Markdown files are translated separately.
The body of the letter exists only in the French and English dictionaries. In Spanish, German, Italian and Portuguese, its paragraphs therefore appear in English, even though the surrounding headings and labels are translated. Their container declares lang="en": this annotation identifies their language without translating them.
This build is chronicled here, article after article. What comes next depends on what you have to say about it.
Join the waitlist