A multilingual Next.js site can serve the right translation and still publish the wrong hreflang relationships. Check the actual URLs and annotations for each group of equivalent pages: which translations are published, whether they reference themselves and one another, and whether canonicals point to the intended language pages.
These 14 hreflang checks follow that process from your content records to the deployed response. They include an App Router example with different English and Turkish slugs, plus checks for Pages Router and next-intl projects. The guidance is based on current Google and Next.js documentation, checked on October 24, 2026; the code is an illustrative fixture, not a claim of production testing.
Hreflang helps Google understand alternate versions for different audiences. It does not translate a page, make an unpublished URL available, or guarantee indexing. Start with a small group of real translated pages and make its expected relationships explicit before auditing the whole site.
1. Build the cluster from published equivalents
A locale in your routing configuration is not evidence that a translation exists. Build each hreflang set from equivalent published content, using a stable content ID to connect records whose slugs may differ.
For example, this inventory contains two translations of one guide and only one published version of another:
content_id,locale,path,published
guide-101,en,/en/guides/site-audit,true
guide-101,tr,/tr/guides/site-denetimi,true
guide-102,en,/en/guides/redirects,true
guide-102,tr,/tr/guides/yonlendirmeler,false
The first guide has a two-page cluster. The second must not advertise its unpublished Turkish record. If your CMS includes scheduled content, use its actual public availability when building this inventory. A record that will become public tomorrow is not an alternate today.
Google's localized-version guidance concerns equivalent versions of a page. Sending every missing translation to a locale homepage changes the destination's purpose and makes the cluster misleading.
Record the expected language-to-URL map beside each content ID. This becomes the common input for metadata, language-switch links and any sitemap annotations you maintain. Later checks compare deployed output against this map; they should not reconstruct it by replacing the first segment of the current pathname.
2. Validate the language and region labels
Check the values that will appear in hreflang, rather than assuming your CMS locale identifiers are valid annotation labels. An application might store english, en_US or an internal locale ID; those values need an explicit mapping to Google's supported format.
Use a supported language code, optionally followed by a region where regional targeting is intended. For example, en identifies English generally, while en-GB identifies English for the United Kingdom. Use en-GB, not en-UK. A country code by itself does not identify the language. Google also documents supported script combinations where they are relevant.
Keep these concepts separate:
| Application decision | Example | What to verify |
|---|---|---|
| Content language | Turkish | The visible page is actually Turkish |
| Annotation label | tr |
Valid language label for that audience |
| Route segment | /tr/ |
The route resolves to that published record |
| Regional variant | en-GB |
A deliberate UK version exists |
Do not add regional variants merely to increase the number of annotations. Check each distinct audience against an actual page. Use consistent casing for readability, but do not diagnose case differences alone as a failure: Google treats these labels case-insensitively. The accepted formats and examples are documented in the language-code section of Google's localized-version guidance linked above.
3. Use the final absolute localized URLs
Inspect the generated annotations for fully qualified public URLs. The expected English alternate in this example is https://example.com/en/guides/site-audit, not /en/guides/site-audit or a preview deployment's hostname. Its Turkish equivalent is https://example.com/tr/guides/site-denetimi; changing only /en/ to /tr/ would produce the wrong slug.
Next.js can resolve relative metadata inputs through metadataBase. That is an implementation convenience, not permission for relative URLs in the final hreflang output. Verify the resolved result, especially when a layout sets a base path or different environments supply different origins. The Metadata API URL-composition rules explain how these inputs interact.
For every expected alternate, compare its scheme, hostname, locale path, translated slug and trailing-slash policy with the final public destination. Prefer the destination directly instead of advertising an old URL that redirects there. Remove accidental tracking parameters or preview parameters from the identity map.
An alternate can legitimately live on another domain. The test is whether it is the intended published equivalent, not whether every URL shares one hostname. Save the exact expected strings so a later build cannot silently replace your production origin with localhost or a staging host.
4. Include the page itself in its alternate set
A common implementation filters out the current locale before rendering the alternates. That may make sense for a language-switch menu, where linking to the current page can be unnecessary. It is the wrong rule for hreflang. Each version should include itself as well as its alternate versions.
For the English guide, the annotation set contains both entries:
<link rel="alternate" hreflang="en"
href="https://example.com/en/guides/site-audit" />
<link rel="alternate" hreflang="tr"
href="https://example.com/tr/guides/site-denetimi" />
The Turkish guide should carry the same set, including its own tr entry. The canonical is a different annotation and does not replace that self reference.
Test this as an explicit assertion: the language-to-URL map contains the current record's locale, and that entry equals the current record's intended public URL. Do not infer a pass simply because another page in the cluster links to this one.
If you prepare annotations manually, generate them from the complete inventory rather than a menu's filtered items. The generator below can format a supplied set; it does not fetch the destinations, establish that they are equivalent, or install the output on your website. Those remain separate checks.
5. Check return links from every published variant
Checking only the page you are editing misses one-way relationships. When the English page identifies the Turkish page as its alternate, inspect the Turkish page too: it needs the corresponding link back.
A two-page acceptance matrix is small enough to review directly:
| Response inspected | English entry | Turkish entry |
|---|---|---|
| English guide | Its own English URL | Published Turkish equivalent |
| Turkish guide | Published English equivalent | Its own Turkish URL |
For a larger cluster, repeat the comparison for every published member against the expected map. Google's guidance calls for the same set across versions and reciprocal links. It also explains that missing return links can cause the affected annotations to be ignored; avoid turning that into the claim that one missing edge necessarily invalidates every relationship on the entire site.
This check matters during staggered publishing. Your English deployment might advertise a newly released translation while a cached Turkish response still contains yesterday's set. A correct database record does not establish a working return link.
Record the two response URLs, the observed language-to-URL entries and the observation time. When an edge is missing, fix the emitting page or its stale output, then fetch both ends again. Do not add unrelated pages to make the matrix look complete.
6. Align canonicals with the intended language page
Read canonical and hreflang annotations together. In the example with distinct English and Turkish translations, each page declares its own intended URL as canonical. Pointing the Turkish page's canonical to the English translation sends a conflicting identity signal.
<!-- On the Turkish guide -->
<link rel="canonical"
href="https://example.com/tr/guides/site-denetimi" />
Google's canonicalization guidance says to specify a canonical in the same language when using hreflang, or the best substitute language when that is unavailable. A canonical declaration is a signal, not proof that Google selected that URL.
Avoid converting this example into a universal self-canonical rule. Nearly identical same-language regional pages may need a deliberate consolidation policy. Decide which URL should represent duplicated content, then make the regional annotations and internal links consistent with that decision. Distinct translated pages and regional duplicates are different cases.
Check for a root-layout canonical inherited by every child route, a second canonical from a plugin, and a header canonical that disagrees with HTML. The checker below can inspect declared canonical information and its target within its documented limits. It cannot tell you Google's selected canonical; use Search Console evidence for that separate question.
7. Generate page-specific App Router metadata
In an App Router project, resolve the current page first, then derive its canonical and alternate URLs from the published records sharing its content ID. This prevents a site-wide locale list from promising translations that do not exist.
The following complete page fixture belongs at app/[lang]/guides/[slug]/page.tsx. It assumes your project already has its required root layout and sets the document's language there. Replace the small array with your real content lookup and public origin. It deliberately includes an unpublished translation to show how availability is filtered.
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
type Locale = 'en' | 'tr';
type Guide = {
contentId: string;
locale: Locale;
slug: string;
title: string;
body: string;
published: boolean;
};
type Props = {
params: Promise<{ lang: string; slug: string }>;
};
const origin = 'https://example.com';
const guides: Guide[] = [
{
contentId: 'guide-101', locale: 'en', slug: 'site-audit',
title: 'Website audit', body: 'Review your published pages.',
published: true,
},
{
contentId: 'guide-101', locale: 'tr', slug: 'site-denetimi',
title: 'Site denetimi', body: 'Yayımlanmış sayfalarınızı inceleyin.',
published: true,
},
{
contentId: 'guide-102', locale: 'en', slug: 'redirects',
title: 'Redirects', body: 'Review changed page addresses.',
published: true,
},
{
contentId: 'guide-102', locale: 'tr', slug: 'yonlendirmeler',
title: 'Yönlendirmeler', body: 'Değişen sayfa adreslerini inceleyin.',
published: false,
},
];
function resolveGuide(lang: string, slug: string): Guide {
if (lang !== 'en' && lang !== 'tr') notFound();
const guide = guides.find(
(item) => item.locale === lang &&
item.slug === slug && item.published,
);
if (!guide) notFound();
return guide;
}
function guideUrl(guide: Guide): string {
return new URL(
`/${guide.locale}/guides/${encodeURIComponent(guide.slug)}`,
origin,
).href;
}
function publishedVariants(guide: Guide): Guide[] {
return guides.filter(
(item) => item.contentId === guide.contentId && item.published,
);
}
export async function generateMetadata(
{ params }: Props,
): Promise<Metadata> {
const { lang, slug } = await params;
const guide = resolveGuide(lang, slug);
const languages = Object.fromEntries(
publishedVariants(guide).map(
(variant) => [variant.locale, guideUrl(variant)],
),
);
return {
title: guide.title,
alternates: {
canonical: guideUrl(guide),
languages,
},
};
}
export default async function GuidePage({ params }: Props) {
const { lang, slug } = await params;
const guide = resolveGuide(lang, slug);
return (
<article>
<h1>{guide.title}</h1>
<p>{guide.body}</p>
</article>
);
}
For guide-101, both pages produce the English and Turkish entries, including themselves. For guide-102, only English is advertised; requesting its unpublished Turkish route reaches notFound(). Enforce one published record per content ID and locale in the real data model so duplicate records cannot silently overwrite an entry.
Current Next.js documentation uses Promise-based params; match your installed version. Metadata exports belong in Server Components, and nested metadata objects merge shallowly. Return the complete alternates object together instead of assuming a child's languages will preserve a parent's canonical. Our guide to Next.js metadata mistakes covers those broader inheritance and freshness issues.
This fixture does not implement a CMS, language switcher, sitemap or publication cache invalidation. Verify those separate surfaces against the same inventory before release.
8. Identify every layer that emits hreflang
A correct generateMetadata function can coexist with incorrect annotations emitted somewhere else. List the owners of HTML alternates, HTTP Link headers and sitemap entries before changing any one of them.
| Project surface | Ownership check |
|---|---|
| App Router | Inspect the page's Metadata API output and inherited layouts |
| Pages Router | Inspect the page or shared next/head implementation |
| next-intl routing middleware | Inspect automatically generated alternate Link headers |
| Proxy or hosting layer | Check whether response headers are added or rewritten |
Next.js Pages Router internationalization handles routing but cannot know every page's language variants. You still supply hreflang annotations. Do not apply a Pages Router configuration recipe to App Router metadata without checking which router owns the route.
The next-intl routing documentation describes automatic alternate links in response headers. It also documents alternateLinks: false for cases requiring custom handling, including page-specific availability and localized pathnames from a CMS. Disable automatic annotations only when you provide the correct replacement.
Multiple supported annotation methods are not inherently an error. The practical failure is disagreement: a header advertises an unpublished German page while HTML lists only English and Turkish. Choose a maintained owner for each surface, derive implemented copies from the same records, and inspect the final response headers as well as the document.
9. Test missing locales and indexability
Now check whether every advertised destination works as a public page. A valid-looking URL in your content inventory can still return a redirect, authentication screen, soft error, or noindex response. Fetch the page and inspect its actual body and indexing controls.
For the fixture above, use this acceptance table:
| Request | Expected result |
|---|---|
/en/guides/site-audit |
Published English guide; English/Turkish alternates |
/tr/guides/site-denetimi |
Published Turkish guide; same alternate set |
/tr/guides/site-audit |
Missing localized slug; not an English fallback page |
/tr/guides/yonlendirmeler |
Unpublished record; not advertised or served as published content |
/de/guides/site-audit |
Unsupported locale rejected |
On the deployed site, verify the observed status, meaningful translated content, robots accessibility, meta robots and X-Robots-Tag. An HTTP 200 alone does not show that the destination is an indexable translated article. Likewise, removing noindex does not guarantee Google will index it.
Do not solve a missing translation by returning the English article under a Turkish URL with a Turkish hreflang label. Google determines a page's language from its content, not that label. Exclude unavailable translations until they are genuinely published, and inspect how your framework delivers missing-page responses rather than assuming a route guard proves the production result.
10. Keep localized routes reachable without negotiation
Open a localized URL without a saved language cookie and without an Accept-Language preference. It should still make the chosen content accessible. A visitor who follows the Turkish guide's link should not be silently sent to English because of a browser setting or an IP-based guess.
Google's multi-regional site guidance recommends separate URLs and cautions against automatic language redirects that prevent access to other versions. Googlebot commonly crawls from US IP addresses without an Accept-Language header, so language negotiation alone is a poor discovery mechanism.
Distinguish two requests in your routing logic. An unprefixed entry point may offer a selector or negotiate a destination. A route that already contains a supported locale should retain that explicit choice. Next.js's internationalization example checks for an existing locale prefix before applying its negotiation redirect.
Also follow the language switcher as a normal hyperlink. Its Turkish destination for the audit guide is /tr/guides/site-denetimi, not a guessed path or an on-click state change with no navigable URL. On a page with no Turkish translation, do not present a link that implies one exists.
Record the final URL and visible language for a fresh request to each variant. This check verifies access behavior; the earlier inventory and annotation checks establish which variants belong together.
11. Choose x-default for a real fallback journey
x-default is optional. It identifies a destination for users whose language settings do not match your listed variants. It does not create a redirect, translate the content, or replace the ordinary language entries.
For a translated guide, decide what a visitor should actually reach. A guide-specific selector can preserve the content context; a deliberately chosen default-language guide can also provide a useful destination. Sending every unmatched visitor to the site's homepage may discard the task they were trying to complete. Do not generate that target automatically without reviewing the journey.
If this guide's intended fallback is its English version, the additional annotation would be:
<link rel="alternate" hreflang="x-default"
href="https://example.com/en/guides/site-audit" />
The normal en entry still belongs in the same set. Apply the chosen fallback consistently to the relevant cluster and verify that it resolves to the intended destination. If you have not defined a useful fallback policy, the fixture can keep its ordinary language alternates without adding x-default.
Check middleware output too. An automatically generated fallback URL may use an unprefixed route that performs language negotiation. Follow it and confirm the result preserves the expected content rather than assuming the presence of x-default makes the behavior correct.
12. Reconcile sitemap annotations when you use them
Google supports hreflang in HTML, HTTP headers and XML sitemaps as equivalent methods. You do not need to add all three, and duplicating the same information across them provides no additional search benefit by itself. Check the surfaces you actually maintain.
If your Next.js sitemap contains language annotations, derive them from the same published equivalents used for metadata. Each localized URL's sitemap entry should carry the complete relevant alternate set, including itself. Publishing only the English entry with alternate links is not the same as providing the corresponding entries for every listed version.
Next.js documents this output through alternates.languages in the sitemap file convention. For the fixture's first guide, inspect entries for both the English and Turkish URL. Neither should invent a German version or reuse the untranslated English slug under /tr/.
After a translation is unpublished or its slug changes, compare the sitemap response with the page annotations. A correct page paired with yesterday's XML is an unresolved inconsistency, even if both are produced from code that looks reasonable.
When sitemap hreflang is not implemented, mark this check as not applicable and verify your chosen annotation method thoroughly. Keep ordinary sitemap discovery and unrelated sitemap optimization outside this particular hreflang check.
13. Inspect complete responses and the effective document
Capture the deployed response instead of searching only your source files. These commands make full GET requests, save headers separately and retain the complete body. Replace the example URLs with the exact public URLs from your inventory.
curl --silent --show-error --location --max-time 30 \
--dump-header en.headers --output en.html \
--write-out 'English: %{http_code} %{url_effective}\n' \
'https://example.com/en/guides/site-audit'
curl --silent --show-error --location --max-time 30 \
--dump-header tr.headers --output tr.html \
--write-out 'Turkish: %{http_code} %{url_effective}\n' \
'https://example.com/tr/guides/site-denetimi'
Inspect redirect history in the header files, then compare canonical and language entries in the complete HTML and any HTTP Link headers. Use a browser's effective document to check where the annotations end up after rendering. A string search is a useful starting point, not proof that the document structure is correct.
Current Next.js supports streaming metadata for dynamic pages. An absent alternate in the first response chunk does not establish that the completed response lacks it. Some request profiles receive blocking metadata behavior; inspect the behavior relevant to your project rather than disabling streaming globally to fix a guessed problem. A request with a Googlebot user-agent string is only a simulation, not evidence of what genuine Googlebot received.
Screpy publishes this guide and provides the tester below. It checks delivered HTML and HTTP hreflang headers within its documented crawl limits. It does not execute JavaScript or inspect sitemap-only annotations. Use its findings alongside the response captures, not as proof of indexing or the entire site's correctness.
14. Recheck the cluster after a publishing change
Make hreflang verification part of the content release, not only the first implementation. Publish a translation, change its slug, or unpublish it, then fetch every affected ordinary public URL again. Check any implemented sitemap annotations too.
Use a record like this for each changed content group:
Content ID: guide-101
Change: publish Turkish translation
Expected variants: en, tr
Expected English URL: https://example.com/en/guides/site-audit
Expected Turkish URL: https://example.com/tr/guides/site-denetimi
Release or content version: [record actual version]
Observed UTC: [record actual time]
English response: [status, canonical, en/tr entries]
Turkish response: [status, canonical, en/tr entries]
Return links: [pass/fail with observed URLs]
Other annotation surfaces: [consistent/not used/pending]
Public output matches expected version: [yes/no]
Unresolved issue and owner: [record or none]
Repeat the exercise in reverse when unpublishing a translation: the remaining pages must stop advertising it. For a slug change, verify both the intended new destination and the site's chosen handling of the old URL. Do not assume a successful CMS save refreshed every cached response.
If ordinary URLs still serve old annotations, keep the release verification pending and investigate the publication or cache behavior you actually use. A cache-busting preview can help diagnose differences but does not prove the ordinary page is current.
For a broader release workflow, see our SEO checks with Claude Code. For this change, the acceptance evidence is narrower: real translated pages, consistent canonical identities, and the expected hreflang relationships visible on the published URLs.