Nine days ago I wrote about getting a solo project indexed on Google, Bing, and Yandex. Today's post is the uncomfortable follow-up: something in that same multi-language setup was quietly working against me the whole time, and it never once threw an error.
The setup
DukoTools supports 5 languages: English, Spanish, Arabic, French, Urdu. 112 tools. A language switcher in the header. hreflang tags on every page telling search engines "here's the Spanish version, here's the Arabic version" of whatever you're looking at.
I assumed that if a page existed at /ar/tools/some-tool, it meant the Arabic translation existed too. That assumption was wrong for 22 tools, and I only found out because someone left a comment on my last post.
The bug, in one sentence
A page can return HTTP 200, declare lang="ar", claim via hreflang to be the authoritative Arabic version of itself and still render 100% English text in the body.
That's called a soft 404. It's invisible to a human. A visitor sees a normal page. Nothing looks broken. But a crawler sees a page that claims to be something it isn't, and search engines treat that mismatch as a trust signal, the wrong kind.
How it happened
The "which locales does this tool exist in" logic was duplicated in five different places:
// LanguageSwitcher.tsx
const LOCALES = ['en', 'es', 'ar', 'fr', 'ur'];
// toolMetadata.ts (hreflang generation)
const LOCALES = ['en', 'es', 'ar', 'fr', 'ur'];
// page.tsx (generateStaticParams)
const LOCALES = ['en', 'es', 'ar', 'fr', 'ur'];
// sitemap.ts
const LOCALES = ['en', 'es', 'ar', 'fr', 'ur'];
Same array, four times. Each one assumed every tool had content in all 5 languages. None of them checked. When a tool's translation didn't exist, the content component did this:
const localeAbout = messages.toolAbout?.[slug];
const displayAbout = localeAbout ?? content.about; // silent English fallback
The ?? fallback is reasonable-looking code. It's also exactly how you build a page that lies about its own language.
The fix: one source of truth
// lib/toolLocales.ts
export function getAvailableLocales(slug: string): AppLocale[] {
return ALL_LOCALES.filter(locale =>
locale === 'en' || hasRealTranslation(slug, locale)
);
}
Every consumer, the switcher, the hreflang generator, generateStaticParams, the sitemap, now reads from this one function instead of a hardcoded array. If a tool isn't translated into Arabic, getAvailableLocales doesn't include 'ar', generateStaticParams never builds that page, and requesting it returns a real 404.
export const dynamicParams = false; // reject any combo not in generateStaticParams
export default async function ToolPage({ params: { locale, slug } }: Props) {
if (!isLocaleAvailableForTool(locale, slug)) notFound();
// ...
}
Two layers of defense: the static generation gate, and an explicit runtime check in case the gate ever gets bypassed.
The part that surprised me more
While auditing this, I checked every tool's messages.tools[slug].name, the actual page heading, against all 5 locale files.
9 out of 112 tools had a translated heading. The other 103, including most of what I thought was my "fully translated" content, fell back to English for the H1 regardless of which language a visitor selected. Since launch. Because the heading lives in a completely different namespace (tools[slug]) than the SEO content (toolAbout, toolFeatures, etc.), and nobody had populated it for anything past the original 9-tool launch batch.
Nothing crashed. Nothing errored. A user could select Urdu and see a page with a fully translated FAQ section sitting under an English title. It just quietly under-delivered, for months, and there's no error log for "the wrong text is technically valid text."
The 404 page needed fixing too
The default Next.js 404 has no locale awareness. Hit a broken link under /ar/... and you got English, LTR, unstyled, and worse, no noindex tag, meaning it could get indexed as if it were real content.
// app/[locale]/not-found.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = await resolveLocale(params);
return {
title: t('title'),
robots: { index: false, follow: false }, // never index a 404
// deliberately no `alternates` — a 404 has no cross-language counterpart
};
}
A 404 page has no real translation, so it shouldn't claim to have hreflang alternates either. Annotating it would be its own small lie.
What I'd tell someone building multi-language from scratch
- One function, not one array copied five times. If "which locales exist for this content" can drift between your switcher and your build config, it will.
- Untranslated should mean 404, not English-in-a-foreign-language-wrapper. A missing page is honest. A mistranslated-looking page is a trust problem.
- Audit your headings separately from your body content. They're probably in a different namespace than you think, and nobody checks page titles the way they check page copy.
-
Your 404 page is still a page.
noindexit explicitly. Don't rely on the HTTP status code alone.
Status
15 of the 22 originally-broken tools are now fully translated across all 4 non-English locales, verified by literally curling the URLs and checking for English leakage in the rendered body. The remaining 7 are tools that aren't shipped yet, so they're correctly absent rather than faked.
The heading gap (103 of 112 tools) is a bigger, separate cleanup I'm working through in batches rather than all at once, same lesson as everything else this week: verify in small, provable pieces, not in one giant sweep you have to trust blindly.
Building DukoTools solo, 106+ free tools, no ads, 5 languages (in progress, apparently more "in progress" than I thought).
Top comments (0)