Bilingual Technical SEO: hreflang for Indonesian and English

Photo by Internet Archive Book Images via Wikimedia Commons (No restrictions)
No. Google's canonicalisation guidance says that when hreflang is in play you should specify a canonical page in the same language, or the closest substitute if none exists. A canonical on /id/ pointing at /en/ asks Google to index the English URL instead, so the Indonesian page stops competing entirely. The safest shape is a self-referencing canonical on every locale, built by the same helper the sitemap uses.
Both, according to Google. Each language version must list itself as well as all the other versions, and if two pages do not both point to each other the tags will be ignored rather than merely weighted down. The reliable way to guarantee it is a single function that takes only the path and returns the same alternates map for every locale, so a one-way link is not expressible in code.
Google describes x-default as a fallback for users whose language settings do not match any of your localised versions. On a two-language site it usually points at the default locale, which here is the prefixed English URL. What matters more than the choice is consistency: every place the annotation is emitted must name the same target, and it must resolve without a redirect.
Because it inherits its target query from the English page it was translated from, and that is often not the question Indonesian developers actually type. Machine output also translates technical vocabulary that Indonesian developers keep in English, such as deploy, error and rate limit, so the page ends up missing the exact strings people search for. Writing the Indonesian title first, for the Indonesian query, changes what the page competes for.
Read the build output rather than the source, for both locales of the same URL. Grep the generated HTML for the canonical link, the hreflang links and the inLanguage value in the JSON-LD, then check four things differ per locale and one is identical. The canonical must be self-referencing, x-default must resolve without a redirect, inLanguage must match the language of the text, and the hreflang block must be the same on both pages.

Photo by Internet Archive Book Images via Wikimedia Commons (No restrictions)
Key Takeaway
Bilingual technical SEO fails at two points: a canonical that crosses languages, and an Indonesian page that only translates the English one. Give every locale a self-referencing canonical, reciprocal hreflang including x-default, and a title written for the Indonesian query, which is frequently a different question rather than the same question translated.
The bug was invisible from inside the site. Every Indonesian article rendered an Indonesian heading, an Indonesian body and an Indonesian excerpt, and then advertised itself in search with an English title and an English meta description. Nothing on the page was broken. It showed only in the one place I could not see from my own browser: the result an Indonesian searcher would get, in the wrong language, pointing at a heading it did not match.
This blog runs 576 posts in English and Indonesian on Next.js App Router with next-intl, locale prefix always present in the path. Everything mechanical below comes out of its own code — lib/seo.ts, the post layout's generateMetadata, app/sitemap.ts and i18n/routing.ts. The rules that had to come from Google rather than from me are cited at the end, and I have flagged which is which.
One SEO title and one description per post live in lib/blog-meta.ts, hand-written in English and length-checked against Google's truncation budget. The Indonesian route read the same object, because it was the only object there was. That is the entire bug: a per-post field quietly doing a per-locale job, on 576 posts at once.
The translated strings already existed. Every post carries an Indonesian heading and excerpt in messages/id.json, because that is what the Indonesian page renders. Mapping those onto the metadata slots costs one small function, and it makes the disagreement structurally impossible rather than merely fixed: the description now resolves through the same key as the visible text, so a future edit cannot move one without the other.
// lib/blog-seo.ts
// English keeps the hand-written SEO pair from lib/blog-meta.ts: keyword
// first, length-checked, deliberately NOT the same string as the display
// heading. Indonesian has no such pair, so it falls back to the translated
// heading and excerpt the page already renders — which means the metadata
// and the visible H1 cannot disagree, because they come from one key.
export function resolveBlogSeoText(
locale: SiteLocale,
seo: BlogSeoText,
localized: { title?: string; excerpt?: string },
): BlogSeoText {
if (locale === "en") return seo;
return {
title: localized.title?.trim() || seo.title,
description: localized.excerpt?.trim()
? truncateForMetaDescription(localized.excerpt)
: seo.description,
};
}The excerpt is display copy, not search copy, so it needed a cut. Across the catalogue the median excerpt runs to roughly 169 characters against a 155-character budget, and a hard slice breaks a word mid-syllable, which reads as broken copy in the result. Trimming on the last space and appending an ellipsis is not clever engineering, but it is the difference between a snippet that reads and one that looks abandoned.
This is the mistake that costs the most and looks the most harmless. Point the Indonesian page's canonical at the English URL and you have not deduplicated anything. You have asked Google to index the English page instead, and the Indonesian version stops competing for anything at all. No build fails, no linter complains, and the page keeps rendering perfectly for every human who visits it.
Google's canonicalisation guidance addresses this case directly: when hreflang is in play, specify a canonical page in the same language, or the best possible substitute language if none exists. It also recommends a self-referential canonical on the canonical page itself. And it is explicit that none of these methods are required, which is worse rather than better — canonical is a signal, so a wrong one is absorbed silently and you learn about it from missing pages, not from an error.
// lib/seo.ts — one builder, used by generateMetadata AND by app/sitemap.ts,
// so the two can never disagree about the locale prefix or the trailing slash.
export function buildCanonical(locale: SiteLocale, path = ""): string {
const url = `${BASE_URL}/${locale}${path}`;
return url.endsWith("/") ? url : `${url}/`;
}
export function buildLanguageAlternates(path = ""): Record<string, string> {
return {
en: buildCanonical("en", path),
id: buildCanonical("id", path),
"x-default": buildCanonical("en", path),
};
}
// In the post layout, per locale:
//
// alternates: {
// canonical: buildCanonical(pageLocale, routePath), // Right: itself
// languages: buildLanguageAlternates(routePath), // same map both sides
// }
//
// Wrong: canonical: buildCanonical("en", routePath)
// Rendered on /id/, that asks Google to index the English URL instead.
// The Indonesian page stops competing for anything, and nothing errors.Build the canonical and the hreflang map from the same function. When one is hand-written per route and the other generated, they drift on the first post whose path changes, and a canonical pointing at the other language is indistinguishable from a harmless typo right up until the page leaves the index.
Google states two conditions and one consequence. Each language version must list itself as well as all the other versions, the pages must point at each other, and if two pages do not both point to each other the tags will be ignored. Not downgraded, not weighted less — ignored, which on a two-language site means the pairing you built the whole system for simply does not exist.
Reciprocity is trivial to satisfy and easy to lose, so the safest implementation is one that cannot express the broken state. Here buildLanguageAlternates takes a path and nothing else, and returns the same three-entry map — en, id, x-default — regardless of which locale asked for it. The English page and the Indonesian page therefore emit byte-identical hreflang blocks, and there is no code path that can produce a one-way link.
x-default is the entry Google describes as a fallback for users whose language settings do not match any of your localised versions. Ours resolves to the English URL, because English is the default locale and the unprefixed path is not a real page here. The value that matters is not which locale you choose but that it is the same target in every emission of the annotation, which is the failure the next section is about.
Google permits three implementations — HTML link elements, HTTP headers, or the sitemap — and says plainly that using all three brings no benefit in Search and is much harder to manage than picking one. The harder-to-manage half is not a style note. On this site it produced two systems that each believed they owned the answer, and they gave different ones.
next-intl's middleware provides alternate links for search engines as part of its job. The pages already emitted hreflang in head through the Next.js alternates.languages field, so the middleware's set was a duplicate — and not an identical duplicate. Its x-default pointed at the unprefixed path, which 307-redirects, while the HTML pointed at the prefixed English URL. One response, two annotations, two different x-default targets, and Google's stated behaviour for conflicting annotations is to discard them.
// i18n/routing.ts
export const routing = defineRouting({
locales: ["en", "id"],
defaultLocale: "en",
localePrefix: "always",
// The locale is always in the URL path, so the NEXT_LOCALE cookie is
// redundant. Disabling it removes the per-response Set-Cookie that was
// stopping Cloudflare from edge-caching the static HTML.
localeCookie: false,
// Every page already emits hreflang in head via alternates.languages.
// Leaving this ON emitted a SECOND set as an HTTP Link header whose
// x-default pointed at the unprefixed "/blog/SLUG/" — a URL that
// 307-redirects, and a different target from the "/en/blog/SLUG/" in the
// HTML. Google may discard hreflang entirely when the annotations
// conflict, which on a bilingual site risks losing the en/id pairing.
alternateLinks: false,
});The fix was one flag, and the reasoning generalises: emit the annotation from the layer that already holds the most context. The metadata layer knows the slug, the locale and the canonical it just built one line earlier; the middleware knows a URL and a locale list. Turning the flag off also let a second change land, because the locale is always in the path and the NEXT_LOCALE cookie was therefore redundant — dropping it removed a per-response Set-Cookie that had been stopping Cloudflare from edge-caching the static HTML.

The argument against machine translation here is mechanical rather than moral. A translated page inherits its target query from the page it was translated from. The English post was written for an English question, so running it through a translator produces an Indonesian page competing for that same question, expressed in Indonesian words. If Indonesian developers do not phrase the question that way, the page is not a weaker answer to a live query — it is an answer to a query nobody types.
There is a second-order effect that is easier to observe. Machine output translates the technical vocabulary too, and technical vocabulary is exactly what Indonesian developers leave in English. A page that says perangkat lunak sumber terbuka where every reader would have written open source is not incorrect, it is unrecognisable, and it fails to contain the string anyone searched for. The words most likely to be translated are the words that most needed to survive.
Google's guidance on multilingual sites makes the mild version of this point: translating only the boilerplate while keeping the bulk of the content in one language creates a bad user experience. The stronger version is the one that decides whether the work is worth doing. A second locale nobody searches for is not a second entry point. It is a second URL you have to keep in sync with the first one, forever, for no traffic.
Once the second locale is targeting its own query, keyword work stops being translation and becomes a small piece of research. The pattern I keep finding is that the technical noun stays English while the framing around it gets translated. Indonesian developers write cara deploy, not cara menyebarkan. They search error 502 nginx rather than reaching for a dictionary word.
| Term or framing | How it shows up in an Indonesian query | What that changes in the title |
|---|---|---|
| deploy | Stays English. The natural phrasing is cara deploy ke VPS, never cara menyebarkan ke VPS | Keep the verb intact and translate only the question word wrapped around it |
| error | Stays English, and so does the error string itself. The dictionary word galat belongs to translated UI, not to search boxes | Put the literal error text in the title and leave it unlocalised |
| rate limit | Stays English. There is no settled Indonesian equivalent a backend developer would actually type | Leave the concept in English and translate the sentence holding it |
| how to, guide, difference | Translated, and this is the productive half: cara, panduan, bedanya, kelebihan dan kekurangan | This is where the Indonesian title diverges most from the English one |
| cost and pricing | Translated and re-denominated: biaya, harga, per bulan, rupiah rather than dollars | An infrastructure cost post needs local units in the title or it answers a different question |
I have no query data behind that table, and I am not going to pretend otherwise. There is no Search Console property for this domain, so the rows come from how the developers around me actually write and from what my own Indonesian drafts kept wanting to say — a weaker basis than analytics and a considerably stronger one than a translation engine. Treat it as a prior to test against your own property, not as a finding.
The practical consequence is where the Indonesian title comes from. On this site the Indonesian meta title is the translated display heading, resolved through the same function that fixed the description, so that heading is the keyword decision rather than a caption. Writing it as a literal translation of the English title throws away the only opportunity the page has to answer a different question, and there is no second slot to recover it in.
Draft the Indonesian title before you translate a single paragraph of the body. If the honest Indonesian title turns out to be a straight translation of the English one, the post probably has nothing extra to say in Indonesian, and the hour is better spent on a post that does.
The sitemap loops over both locales and over each content registry, so a post enters it by being registered rather than by anyone editing XML. Every entry carries its own locale-prefixed canonical, the same hreflang map, and a lastmod read from the post's dateModified rather than from build time. That last part was a real correction: a lastmod that always reads now teaches Google to ignore the field, and this one had been stamping 68 URLs that had not changed.
Structured data obeys the same rule as the title. The article schema declares inLanguage id-ID on Indonesian pages, so its headline, description and abstract have to be Indonesian too. A localised language tag sitting over English text is a contradiction a crawler can detect without a human reading it, and the repair is the same function that repaired the meta description — one resolver feeding the visible page, the metadata and the schema.
The least glamorous mechanic is the one that actually takes pages down. Both message files must have identical key trees, and next-intl throws on a missing key rather than degrading. A bullet added to the English fragment and forgotten in the Indonesian one does not render a shorter list; it breaks one locale of one page, and that is a failure which can survive for months because nothing else on the site notices. Each file is now over 5 MB, almost all of it article bodies, so the check has to be a script rather than a habit.

Here is the limit of this post. This domain has no Search Console property, so I have no impressions split by locale, no query list, no international targeting report and no hreflang error count. Anything I told you about rankings, click-through or which locale wins would be invented, and invented numbers are the main way posts like this one go wrong. What follows is a method and its reasoning; the numbers have to come from your own property.
What you can verify without any of that is whether the pages emit what you believe they emit, and that is where most of the damage actually lives. Read the build output rather than the source, for both locales of the same post, because the source shows intent and the HTML shows what shipped. Four things must differ between the two files and one must be identical.
# Read the BUILD OUTPUT, not the source. Both locales of one post.
slug=bilingual-technical-seo-indonesian-english
for loc in en id; do
f=".next/server/app/$loc/blog/$slug.html"
echo "--- $loc"
grep -o 'rel="canonical" href="[^"]*"' "$f"
grep -o 'hreflang="[^"]*" href="[^"]*"' "$f"
grep -o '"inLanguage":"[^"]*"' "$f"
done
# --- en
# rel="canonical" href="https://www.matthewswong.com/en/blog/SLUG/"
# hreflang="en" href=".../en/blog/SLUG/"
# hreflang="id" href=".../id/blog/SLUG/"
# hreflang="x-default" href=".../en/blog/SLUG/"
# "inLanguage":"en-US"
#
# --- id
# rel="canonical" href="https://www.matthewswong.com/id/blog/SLUG/" itself
# hreflang="en" href=".../en/blog/SLUG/" byte-identical to the
# hreflang="id" href=".../id/blog/SLUG/" block on the /en/ page
# hreflang="x-default" href=".../en/blog/SLUG/"
# "inLanguage":"id-ID"The four assertions are: the canonical is self-referencing and locale-prefixed on each page, x-default resolves without a redirect, inLanguage is id-ID on the Indonesian page, and the title and description in that file are Indonesian. The identical one is the hreflang block, which should be the same three lines on both. Add the pair of sitemap entries and that is the whole mechanical surface — everything left is editorial.
The mechanics are the cheap half, and they are worth automating until they cannot be got wrong: one function builds the canonical, one builds the hreflang map, both are used by the page and by the sitemap, and neither can express a one-way link. The expensive half is editorial and no script will do it for you. A second locale is a second question, and the moment the Indonesian title becomes a translation of the English one, the page has stopped competing for anything.
Sources and further reading