Next.js hreflang: bilingual SEO with the App Router
Bilingual SEO fails through inconsistency: hreflang without return links, clashing canonicals and missing translations. How to avoid it with Next.js App Router.
BySergio Jáuregui
8 min read
In this article
- Why bilingual SEO fails through inconsistency
- Next.js hreflang: one source of truth
- One URL per language: subfolder, subdomain or domain
- When I would skip the second language
- Sitemap with alternates and real lastmod
- The translation that does not exist yet: hreflang absent on purpose
- JSON-LD with inLanguage and a review before publishing
- Verify your hreflang in Search Console this week
Next.js hreflang fails through inconsistency, almost never through syntax: an alternate without a return link, a canonical that contradicts the cluster, or an hreflang pointing at a translation that does not exist yet is enough for Google to ignore the incoherent part. The fix is a single source of truth: published translations rule, and canonicals, alternates and the sitemap all derive from them.
This guide covers bilingual SEO with the App Router: reciprocity, canonicals, sitemaps, pending translations, structured data and verification. The code you will see is a reference implementation of documented techniques, not a single recipe for every project.
Why bilingual SEO fails through inconsistency
Hreflang works as a cluster: every version of a page links to every other version, including itself. The Google documentation on localized versions requires each link to have its return: if page X points to page Y, page Y must point back to X. When a return link is missing, Google may ignore the link, and the other-language version drops out of the cluster.
The three typical failures are all about coherence. First, non-reciprocal alternates: the Spanish page declares English, but the English page never links back. This is common in templates edited by hand, language by language. Second, a contradictory canonical: a translation that canonicalises to another language tells Google it is a duplicate, the exact opposite of what the hreflang claims. Third, hreflang pointing at something that does not exist: alternates aimed at a draft or at a URL returning 404, because they were written once and nobody updated them when content was unpublished.
The cure is architectural: no hand-written alternates. If alternates derive from published translations, it is impossible to declare a language that does not exist. That rule removes two of the three failures at the root; the canonical deserves its own section.
Next.js hreflang: one source of truth
In the App Router, alternates are declared in metadata. The Next.js documentation on alternates defines canonical and languages, and the framework emits the link tags into the head. A useful pattern is a pure function that receives the published routes for each language and returns only what is true:
// Reference implementation: the alternate exists only if the translation is published
type Slugs = { es: string | null; en: string | null };
export function alternatesFor(base: string, locale: 'es' | 'en', slugs: Slugs) {
const urlFor = (lang: 'es' | 'en', slug: string) =>
lang === 'es' ? `${base}/${slug}` : `${base}/en/${slug}`;
const current = slugs[locale];
if (!current) return {};
const alternates: { canonical: string; languages?: Record<string, string> } = {
canonical: urlFor(locale, current),
};
if (slugs.es && slugs.en) {
alternates.languages = {
es: urlFor('es', slugs.es),
en: urlFor('en', slugs.en),
'x-default': urlFor('es', slugs.es),
};
}
return alternates;
}
Two rules keep it coherent. The canonical is the current locale's URL, with no trailing slash. And languages is emitted only when both translations are published: with a single one, a lonely hreflang adds nothing, and one pointing at a nonexistent page does harm. Mind the slugs too: if Spanish and English use different slugs, the function must receive both, as it does here.
For reference, the blog at sergioja.com applies this same convention, with Spanish at the root and English under /en. The code above is a simplified version of the principle; the reference project shows how it fits into a complete site.
One URL per language: subfolder, subdomain or domain
There are three ways to separate languages: a subfolder (/en/...), a subdomain (en.yourdomain.com) or one domain per language. The subfolder keeps everything under one domain, concentrates signals and simplifies maintenance: one deploy, one sitemap, one property in Search Console. Subdomains or separate domains split the site and only pay off with genuinely separate operations per market.
For a small bilingual project, the subfolder is usually the sensible choice. What matters is that the convention lives in one place, a helper that localises paths and builds each URL in each language. That way canonicals, alternates and the sitemap follow the same rule, and the rule does not depend on whoever writes the next page remembering it.
When I would skip the second language
Two poorly kept languages lose to one well kept. If you cannot publish and update both with the same finish, the stale translation confuses readers and the broken hreflang confuses Google. One language with no hreflang is more honest than two with an incoherent cluster. The real trade-off is maintenance cost: every article, page and structural change doubles.
Sitemap with alternates and real lastmod
The sitemap repeats what the head says in crawlable form, and it must say exactly the same thing. On a mismatch, Google discards the incoherent part. A well-built multilingual sitemap has three blocks: the homepage, which exists in both languages and declares itself a translation of itself; categories, paired by identifier across languages; and content entries, with their real update date and alternates only when both languages exist.
Two details are easy to miss. The first is duplicates: if a piece of content arrives twice, the builder should keep one entry per URL. The second is URL shape: if canonicals carry no trailing slash, the sitemap should not either, because mixing variants splits signals. The robots.txt should allow crawling, block API and preview routes, and declare the sitemap.
Content lists are often cached so the database is not queried on every visit. That cache must be invalidated on publish, not only by time. The mechanism and its traps are covered in the article on Next.js revalidateTag.
The translation that does not exist yet: hreflang absent on purpose
Each translation has its own status: draft, scheduled, published or archived. That lets you ship Spanish today and English next week, and it raises an awkward question: what hreflang does the Spanish page emit in the meantime? The right answer is none toward the missing language. Metadata receives only published routes, the sitemap includes alternates only when both languages exist, and the language switcher renders dimmed instead of linking to a 404. Deliberate absence beats a broken promise.
The same care applies to edge cases. For an unknown slug, the sensible response is a permanent redirect that keeps the locale or, with no destination, a real 404. A temporary API failure must never become a cached 404, because then a real page loses rankings over a passing problem: only a confirmed absence should return null. A draft preview should carry no index and no follow, and declare a public canonical only once the content is published.
A common limit: the pending-translation case is rarely tested when both languages always launch together. Real experience arrives when one language falls behind, so simulate that case before you need it.
JSON-LD with inLanguage and a review before publishing
Each piece of content should emit structured data consistent with its language. A BlogPosting with headline, description, URL, language, dates and author; a BreadcrumbList tracing homepage, category and content; and a FAQPage only when the page has real questions. The language travels in inLanguage, so each translation describes itself without ambiguity.
Before publishing, review the hreflang and canonical pair in this order. First, each translation canonicalises to itself; crossing the canonical between languages declares a duplicate and contradicts the cluster. Second, every alternate exists as a published page and links back. Third, x-default points at a real page. Fourth, canonical, alternates and sitemap share one URL shape. Fifth, the head and the sitemap say the same thing.
A mistake worth avoiding by design is mixing languages in body links. Every internal link should use the routes of its own language, and the language switch belongs in the header, not in the prose. That way a reader never lands in the wrong language by following a link.
Verify your hreflang in Search Console this week
If your Next.js hreflang passes three checks, it works. First, open each translation's source and confirm the alternates exist, point at each other and include the page itself. Second, inspect both URLs in Search Console and confirm Google sees rendered HTML carrying those alternates, not an empty shell. Third, submit the sitemap and watch the performance report for which URL earns impressions in each country: if each version wins where it should, the cluster works.
If your homepage also uses 3D, check first that the text arrives in the server HTML, as the article on 3D website SEO explains. And if the whole website is not showing on Google, the cause is usually surface rather than languages: start with new website not showing on Google.
If your site already has content in two languages, the order is this: unify the URL convention, derive alternates and canonicals from published translations, match the sitemap to the head, and measure. If you want us to review your bilingual case, write to me through the contact page.
Have a project in mind?
I'm Sergio Jáuregui, a Full Stack developer. If you enjoyed this article, take a look at my work or drop me a line.
Keep reading
SEO and visibilityGuide
New website not showing on Google: what to check in Search Console
If a new website is not showing on Google, start with impressions, pages, queries and sitemaps in Search Console. What to check and a plan to follow.
Updated on Oct 15, 2026
Back to top