Building Reciprocal Hreflang Clusters from CMS Translations
This guide, part of Hreflang Tag Generation, implements the one property that makes hreflang work: reciprocity. Every page in a cluster must list exactly the same set of versions, including itself, and every URL in that set must be an indexable, self-canonical page in the stated language. The guide shows how to derive that set from the two ways CMSs store translations, how to decide what counts as “translated”, and how to keep every member’s annotations updated together.
Reciprocity fails for mundane reasons. The English page is rendered on Monday and lists four versions; the Japanese translation is published on Tuesday, and its page lists five; the English page’s cached head still lists four. Or one template builds the cluster from the list of site locales, another from the CMS’s translation data, and they disagree about a page that exists in only three locales. The cure is to compute clusters in one place from one source of truth, and to invalidate every member whenever the cluster changes.
The Problem
A software vendor’s documentation used field-level localization. The hreflang template listed every supported locale for every page, since every entry technically existed in every locale. For pages without translations, the German and Japanese URLs served English fallback content, so search engines found clusters in which half the members were English pages claiming to be German or Japanese, with canonicals pointing back to English. Search console reported the annotations as invalid for most documentation pages, and German users often landed on English content via the German URL.
How to Determine Membership
Field-level localization. Each entry holds values per locale. Decide which fields must be present for a locale to count as translated, typically the title and the main body, and consider the locale a member only if those fields have values in that locale, not inherited through fallback. Record the result per entry and locale when content is published.
Entry-level localization. Each locale is a separate entry linked by a translation group or document id. A locale is a member if its entry is published and indexable. Fetch the group once and use all its published members.
Exclusions in both models. Exclude locales whose page is noindex, whose canonical points elsewhere, or whose route manifest has no path, for example because a parent page is not published in that locale.
Store membership with the route. Keep the member locales as a field on the manifest entry, updated by publish webhooks, so building a cluster needs no extra CMS request.
Implementation
Membership is computed at publish time and stored with the route. For field-level models, a small function checks the required fields per locale without following fallbacks.
// lib/seo/membership.ts
type LocalizedFields = Record<string, Record<string, unknown>>; // field -> locale -> value (no fallback applied)
const REQUIRED: Record<string, string[]> = {
docPage: ["title", "body"],
article: ["title", "body", "summary"],
product: ["name", "description"],
};
function hasValue(v: unknown): boolean {
if (v == null) return false;
if (typeof v === "string") return v.trim().length > 0;
if (typeof v === "object" && "content" in (v as object)) return ((v as { content: unknown[] }).content ?? []).length > 0; // rich text
return true;
}
export function memberLocales(contentType: string, fields: LocalizedFields, locales: string[], flags: { noindex?: Record<string, boolean> } = {}): string[] {
const required = REQUIRED[contentType] ?? ["title"];
return locales.filter((locale) =>
required.every((f) => hasValue(fields[f]?.[locale])) && !flags.noindex?.[locale],
);
}
The webhook handler recomputes membership for the published entry, writes it to the manifest, and revalidates the cluster tag if the set changed.
// app/api/translation-webhook/route.ts (excerpt)
const fields = await fetchEntryAllLocales(entryId); // e.g. locale=* on Contentful
const members = memberLocales(contentType, fields, SUPPORTED_LOCALES, { noindex });
const previous = await manifest.getMembers(entryId);
await manifest.setMembers(entryId, members);
if (members.join() !== previous.join()) {
revalidateTag(`cluster:${entryId}`); // every member page re-renders
await revalidateSitemapChunksFor(entryId, [...new Set([...members, ...previous])]);
}
Every page render tags its data with cluster:{entryId} and builds its hreflang from manifest.getMembers(entryId), which guarantees that all members read the same set. For entry-level models, fetchEntryAllLocales becomes a query for the translation group, and membership is the list of published, indexable members.
Handling the transition period
Between the publish event and the revalidation of every member, caches may briefly disagree. Keep that window short by revalidating the tag, not waiting for cache expiry, and accept that search engines crawling in those seconds may see a partial cluster. What must not happen is a window of days, which is what cache expiry alone produces.
Translation workflows that publish in stages
Many teams publish translations in stages: a machine translation first, then a human-reviewed version days later. Decide whether the first stage counts as a member. If machine-translated pages are published as real localized pages, they belong in the hreflang set, and search engines will judge their quality like any other page. If they are shown only as drafts or behind a notice, keep them out until review. Model the decision as an explicit per-locale status on the entry, such as “machine”, “reviewed” or “complete”, and base membership on it, so the rule is visible to editors and translators rather than hidden in code.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Membership rule | required fields present in the locale, no fallback | Fallback pages are not versions. |
| Exclusions | noindex, foreign canonical, no manifest path | Only indexable, self-canonical URLs. |
| Storage | members on the manifest entry | No extra CMS call per render. |
| Cache tag | cluster:{entryId} on every member |
All members update together. |
| Sitemaps | regenerate chunks of old and new members | Sitemap annotations stay reciprocal. |
| Minimum size | two members | Single versions need no cluster. |
Gotchas & Edge Cases
- Required fields that differ by locale. Some locales legitimately omit a field, such as a regional summary. Keep the required set minimal: title and main content.
- Partially translated rich text. A body that exists but is still mostly English is technically a member. If this is common, add an explicit “translation complete” flag per locale and use it for membership.
- Unpublishing one locale. Removing a member changes every other member’s cluster. Treat unpublish events exactly like publish events.
- Entry-level groups with duplicates. Two entries in the same locale within one group, a data error, produce ambiguous clusters. Validate groups and pick deterministically or fail loudly.
Worked Example
The software vendor replaced its supported-locales loop with membership computed from required fields at publish time, stored on the route manifest, and added cluster tags. The number of documentation pages with a German member dropped from all 4,800 to the 1,900 actually translated, and every cluster now contained only real translations. Search console’s hreflang errors for the documentation fell from thousands to a handful within six weeks, and German users searching for documentation landed on German pages when they existed and on English pages under English URLs otherwise.
Testing Reciprocity
Reciprocity is a property of a set of pages, so test it on sets. In CI, pick a sample of entries with different membership, render every member page of each, extract the hreflang links and assert that all members of an entry produce identical sets, that every URL in each set is in the manifest, and that each member’s canonical equals its own URL. Add fixtures for the tricky cases: an entry translated into one locale, an entry whose middle locale was just unpublished, and an entry with translated slugs. In production, run the same check nightly on a rotating sample of clusters and alert on any mismatch, which catches caching bugs that only appear after real publishing activity.
Rollout Checklist
- Define required fields per content type for membership.
- Compute member locales at publish time and store them on the manifest.
- Exclude noindex, foreign-canonical and path-less locales.
- Tag every member’s cache with the cluster tag and revalidate on change.
- Regenerate sitemap chunks for old and new members.
- Test reciprocity on rendered sets in CI and nightly in production.
Frequently Asked Questions
Why not include fallback pages and let canonicals sort it out?
Because the annotation then says the page is German when it is English, contradicting its canonical. Search engines ignore contradictory annotations, often for the whole cluster.
Should partially translated pages be members?
If the main content is translated, yes. Use an explicit completeness flag if your workflow publishes partial translations.
How often should membership be recomputed?
On every publish and unpublish, and fully in the nightly manifest build as a safety net that repairs anything a missed webhook left behind.
Does membership affect the language switcher?
It should. Build the switcher from the same members, so it never links to fallback pages as if they were real translations of the current page.