What Actually Belongs in a Bilingual Site's <head> — canonical, hreflang, and Structured Data
Adding a full SEO head to a bilingual Astro site. The interesting parts: hreflang group correctness (self-reference + reciprocal + x-default — pointing only at the other language is wrong), not emitting hreflang for posts with no translation, and which JSON-LD an article page versus the home page should emit.
Every post on this site has a Chinese and an English version: Chinese at /blog/<slug>/, English at /en/blog/<slug>/, with Astro’s i18n routing (prefixDefaultLocale: false) putting Chinese at the root and English under /en/. The content has been bilingual for a while, but the <head> stayed thin: just title, description, and one hreflang pointing at the other language. Today I built it into a complete SEO head, and the parts worth writing down are a few details that look right but aren’t.
canonical: on every page, not just the home page
Start with the basics. Every page declares its own canonical URL, telling search engines “no matter which parameterized address you arrived from, this is my true identity”:
const canonical = new URL(Astro.url.pathname, Astro.site);
<link rel="canonical" href={canonical} />
For a static blog, canonical looks redundant — the URLs are already clean. But what it guards against is entry points you don’t control: ?utm_* params tacked on by social platforms, a stray trailing-slash difference when someone shares a link, redirect variants from Pages. One line, and it blocks a whole class of duplicate content.
hreflang: pointing only at the other language is wrong
The original code emitted hreflang like this:
{altHref && (
<link rel="alternate" hreflang={lang === 'zh' ? 'en' : 'zh-CN'} href={altHref} />
)}
The Chinese page emits one alternate pointing at English; the English page emits one pointing at Chinese. Symmetric, reasonable-looking — and wrong.
Google’s requirement for hreflang is: every page in a language group must list all versions in the group, including itself. So the Chinese page can’t just say “my English version is over there”; it must also say “my Chinese version is me” and “my English version is over there”. Missing the self-reference, Google considers the hreflang declaration incomplete and demotes or ignores the whole group.
Rewritten as a complete group:
const zhURL = lang === 'zh' ? canonical : altURL;
const enURL = lang === 'en' ? canonical : altURL;
{zhURL && enURL && (
<>
<link rel="alternate" hreflang="zh-CN" href={zhURL} />
<link rel="alternate" hreflang="en" href={enURL} />
<link rel="alternate" hreflang="x-default" href={zhURL} />
</>
)}
All three are mandatory: zh-CN and en are the two members of the group (list both, no matter which page you’re on), and x-default names the “fallback when no language matches” — I point it at the Chinese site since it’s the default locale. Now the Chinese and English pages emit the same group, mutually corroborating, which is what Google needs to trust it.
A post with no translation shouldn’t emit hreflang
That zhURL && enURL && guard isn’t incidental. The vast majority of posts are bilingual, but the structure allows a Chinese-only post to exist (altHref is empty). In that case:
- there is a canonical — it always points at itself, every page should have one;
- there is no hreflang — a page that only has a Chinese version, emitting a group that claims “the English version is over there” pointing at a 404, is a worse signal than emitting nothing.
So the logic is: emit canonical unconditionally, emit the hreflang group only when a corresponding translation actually exists. Better to declare nothing than to declare a self-contradicting group. This kind of edge — “the feature exists but this one piece of data is missing” — is exactly where SEO meta most easily generates dirty data, worth guarding explicitly.
Open Graph: an article and the home page are different things
Social share cards run on Open Graph. The key distinction here is og:type: the home page is website, an article page is article, and article carries an extra batch of semantics:
<meta property="og:type" content={ogType} />
{ogType === 'article' && pubDate && (
<meta property="article:published_time" content={pubDate.toISOString()} />
)}
{ogType === 'article' && tags.map((tag) => <meta property="article:tag" content={tag} />)}
In implementation, Base.astro gained three optional props — ogType, pubDate, tags. Article pages pass frontmatter through; everything else uses the default website. Add og:locale (zh_CN / en_US) plus og:locale:alternate to declare the other language exists, and Twitter’s summary_large_image card. One layout change, and every page across the site automatically carries correct social metadata.
Structured data: a second copy of the content, for machines
The “heaviest” block in <head> is JSON-LD — a structured description for search engines and AI to read, parallel to the human-facing HTML body. Same branching by page type.
Article pages emit BlogPosting:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "...",
"datePublished": "2026-07-09T...",
"inLanguage": "en",
"mainEntityOfPage": "https://mahui.me/en/blog/...",
"keywords": "SEO, Astro, site",
"author": { "@type": "Person", "name": "Ma Hui", "url": "...", "sameAs": [...] }
}
The home page emits WebSite + Person, where Person’s sameAs ties together GitHub, X, and Zhihu — the standard way to tell Google “these accounts around the web are the same person,” which helps establish entity recognition over time.
A pragmatic check: after the build I pulled every <script type="application/ld+json"> out of dist and JSON.parse’d each — all 80 valid. Structured data is plain string concatenation, and the easiest way to break it is one unescaped quote in a field killing the whole JSON block — running a parse at build time beats seeing an error in Search Console after launch.
Patched along the way: per-language RSS
While reworking the head I found a hole: the site had only one /rss.xml, fed by the Chinese collection. English readers had no corresponding feed, and the RSS link in the English pages’ <head> pointed at the Chinese source. Added a /en/rss.xml:
// src/pages/en/rss.xml.js
const posts = await getCollection('blogEn', ({ data }) => !data.draft);
Then made each page’s RSS <link> point at the right feed by language (rssHref = lang === 'zh' ? '/rss.xml' : '/en/rss.xml'). Every outward-facing entry point on a bilingual site — sitemap, hreflang, RSS — has to come in pairs; miss one and you’ve got half-legged internationalization.
Takeaways
- hreflang is “group” semantics, not a link “pointing at the other one”. Every page must list all members of the group (including itself) plus x-default; emitting a single alternate pointing at the other language is the most common way to get it wrong;
- The edge cases of meta are worth more than the normal case. Emitting hreflang when a translation exists is trivial; what actually determines data quality is remembering not to emit it when there’s no translation — SEO dirty data almost all comes from that missing branch;
- Branch og:type and JSON-LD by page type. Articles are
article/BlogPosting, the home page iswebsite/WebSite; mixing them hands crawlers semantically mismatched metadata; - Validate structured data at build time. It’s JSON assembled from strings; one unescaped quote can void a whole block. Add “parse all JSON-LD” to the build check — one line of cost, and you don’t blow up in production;
- Internationalization entry points come in pairs. sitemap, hreflang, RSS, OG locale — as long as the site is bilingual, these all need two copies, and missing one gives the game away.
Comments