From November 2025 until August 2026 every page of codebiy.com had one address and two languages behind it. Which language you read depended on a cookie. The site was written in English and in Turkish, and a visitor who had never pressed the language switch got English. So did every crawler, on every visit: Google's renderer, for one, clears cookies between page loads.
The Turkish half was written and maintained, and there was no URL that returned it. This is how that happened, what replaced it, and what else turned up once every address had to be written down. The site runs on Next.js 16 with next-intl 4.
How the old setup chose a language
There was no locale in the path and no middleware. The language was decided in next-intl's request configuration, by reading a cookie:
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const localeCookie = cookieStore.get("locale")?.value;
const locale =
localeCookie && locales.includes(localeCookie as Locale)
? localeCookie
: defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
defaultLocale was "en". The language switch called a server action that stored the choice in that cookie for a year, then refreshed the page. Same address, different text.
For a person this works. You press TR once and the site stays Turkish for a year, which is why nothing looked broken from the inside.
Now make the same request the way a crawler does, without a cookie. The answer is English, with <html lang="en">, at the only address the page has. No cookieless request returns the Turkish text, so there is nothing to index, nothing for an hreflang tag to point at and nothing to list in a sitemap. Twenty-eight URLs of bilingual content were served this way.
One address, two languages: without a cookie, a crawler only ever got English.
Two smaller problems came with it. The site's default title and description were one static block of English in the root layout, so Turkish pages carried English metadata. And cookies() is a request-time API in Next.js: a page whose language depends on it cannot be generated ahead of time.
The fix: the locale moves into the URL
Google's advice is to use a different URL for each language version of a page, and not cookies or browser settings. In August 2026 every route moved under app/[locale]/, and next-intl's middleware handles the rest. Since Next.js 16 that file is called proxy.ts:
export default createMiddleware({
locales,
defaultLocale,
localePrefix: "always",
localeDetection: true,
localeCookie: false,
alternateLinks: false,
});
localePrefix: "always" means /en/… and /tr/… are the only addresses a page has. No unprefixed version quietly belongs to one of the languages. That is the point: every page has exactly one address per language, and hreflang has something to point at. The last two options date from October 2026 and are explained below.
The migration brought one bug of its own. The middleware's matcher was the usual pattern, every path without a dot in it. Next's generated metadata routes have no file extension, so when we added a generated Apple icon the same afternoon, /apple-icon was redirected to /en/apple-icon, which does not exist. The fix went into the same change, so the bug was never deployed. Metadata routes are now excluded by name:
matcher: ["/((?!api|_next|apple-icon|icon|opengraph-image|.*\\..*).*)"],
The list has one more entry today, an OAuth redirect URI: the address a third-party sign-in sends the user back to. It is registered with the other side without a locale, and a redirect to /en/… would be a different address from the registered one.
With an address for each language, every page can say what it is through the alternates field of its metadata. This is a blog post's:
alternates: {
canonical: `/${locale}/blog/${slug}`,
languages: {
en: `/en/blog/${slug}`,
tr: `/tr/blog/${slug}`,
"x-default": `/en/blog/${slug}`,
},
types: { "application/rss+xml": `/${locale}/blog/feed.xml` },
},
The last line announces the RSS feed that each language has had since October 2026. The sitemap lists every path once per language, and each entry names both versions:
return locales.flatMap((locale) =>
entries.map(({ path, lastModified }) => ({
url: `${site.url}/${locale}${path}`,
...(lastModified ? { lastModified } : {}),
// changeFrequency and priority omitted
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${site.url}/${l}${path}`]),
),
},
})),
);
The rest followed from the path. Titles and descriptions now come from the same message files as the body, <html lang> matches the segment in the URL, and each page is generated once per language at build time. The language switch became an ordinary navigation: it swaps the first path segment and goes to that address, keeping the query string and the hash.
Where a language guess is allowed
The one place where a guess is allowed is an address that carries no locale. There the middleware reads the browser's Accept-Language header and redirects. We requested the live site without cookies on 9 October 2026:
Request to /blog |
Answer |
|---|---|
Accept-Language: tr |
307 to /tr/blog |
Accept-Language: de |
307 to /en/blog |
Accept-Language: ja |
307 to /en/blog |
| no header | 307 to /en/blog |
Google's crawler sends no Accept-Language header, so it gets the last row.
An address that already carries a locale is not second-guessed: /en/blog answers 200 for a Turkish browser and /tr/blog answers 200 for an English one.
No cookie takes part any more. By default, when a visitor opens a language that differs from the browser's, next-intl stores that choice in a session cookie and prefers it to Accept-Language on addresses without a locale. Our site did that until October 2026. The cookie only decided where /blog redirects: we requested /en/blog with the cookie set to Turkish and got a 200, not a redirect. Every internal link carries its locale, so we switched the cookie off with localeCookie: false.
For search engines the default is declared in the pages themselves. The blog index and the articles carry the x-default line of the snippet above, which says that English is the version for every language without a page of its own. The other pages name their two languages only.
Until October 2026 it was declared a second time. By default next-intl's middleware also sends the alternates as a Link header, and there x-default is the address without a locale. When the blog pages got their own x-default on 9 October 2026, the two named different addresses, /en/blog in the page and /blog in the header. Google's own page on localized versions treats tags and headers as equivalent, so we kept the tags and switched the header off with alternateLinks: false.
Other faults found while listing every address
Giving each page an address per language meant listing every address the site had collected since September 2025. Four things were wrong that had nothing to do with languages.
Two pages for one app. Dusora, our dream journal for iOS, was still called Rüya Defteri then. It had a detail page in the catalogue and a small site of its own; both were in the sitemap, competing for the same query, while its own pages were linked from nowhere except the sitemap. There is one page now, and the old addresses answer with a permanent redirect:
{
source: "/:locale(en|tr)/platforms/ruya-defteri",
destination: "/:locale/dusora",
permanent: true,
},
A sitemap that said everything changed today. The old sitemap stamped new Date() on the home page, the blog index and every product and service page. Each deploy told crawlers that the whole catalogue had just been edited. A date that is always now carries no information, so lastModified is set only where a real date exists, which today means the articles.
A placeholder in production. The site's metadata contained verification: { google: "verification-code-here" }, and that literal placeholder was shipping in the HTML. It is gone, and stays out until there is a real token.
A large image card with no image. The Twitter card type was summary_large_image and there was no og:image anywhere on the site, so every shared link rendered as a blank card. The rebuild added a generated image for each language. On 9 October 2026 we requested that image on the live site and got a 502, and we found that the article pages announced no image at all. Both are repaired, and each article has its own card. How that went unnoticed is told in our article on checking front-end work written by AI agents.
Checks for lang, hreflang and message parity
None of this is hard to break again. A new page without an alternates block, or a message key added to one language file only, would bring a piece of the old problem back, and neither fails a build.
A verification script opens each route it is given, in both languages, in a real browser. It fails if the response is not 200, if <html lang> does not match the locale in the path, or if either hreflang is missing:
if (!meta.title) fail(`title ${target}`, "empty");
else if (meta.lang !== locale)
fail(`lang ${target}`, `got "${meta.lang}"`);
else if (!meta.alts.includes("en") || !meta.alts.includes("tr"))
fail(`hreflang ${target}`, meta.alts.join(",") || "none");
else ok(`meta ${target}`);
A second script compares the two message files key by key. It fails when one language has a key the other lacks, or when a list is longer in one language than in the other.
Neither runs by itself. The message comparison is npm test, which we run before a push. The browser check needs a running build and is started by hand, by us or by the agent on the task; the deploy runs neither. The script above last left a report on 8 August 2026, for the home page in both languages. The last browser check of status, canonical address and hreflang was a separate run on 9 October 2026, when the current articles went out: the home page, the blog index and every article, in both languages, on the live site.
What a crawler receives now
We have no index counts to show. On 9 October 2026 we could not get a usable count of indexed pages, and we kept no search data from before the change, so we will not say what it did for rankings or traffic.
What we can show is what a request without cookies receives, which is the table above. Before August 2026 no such request returned Turkish. Now /tr/blog answers with <html lang="tr">, names its English counterpart and appears in the sitemap.
A checklist for a site in more than one language
Two requests show what a crawler gets. Replace the address with your own:
# an address without a locale, as a Turkish browser
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
-H 'Accept-Language: tr' https://codebiy.com/blog
# a page's own address, without cookies
curl -s https://codebiy.com/tr/blog | grep -o '<html[^>]*lang="[a-z]*"'
On our site the first prints 307 https://codebiy.com/tr/blog and the second <html lang="tr". Two more checks take minutes:
- Run
curl -sIon a page and read theLinkandSet-Cookieheaders. Your middleware may already sendhreflangthere. - Open a shared link and look at the card, then request the image address itself.
The fixes take longer:
- Give each language its own URL, and have each page declare its canonical address and its
hreflangcounterparts, with onex-default. - Guess the language only on addresses that carry none. A URL with a locale in it should never redirect by browser language.
- Keep extensionless metadata routes, and any OAuth redirect URI registered with a third party, out of the locale matcher.
- List both languages in the sitemap, and send
lastmodonly when it is true. - Put the
langandhreflangchecks in a script, and decide who runs it and when.