The /en/about Bug: When Next.js Pages Ignore Route Params
The /en page kept showing Indonesian content while the build stayed green: synchronous page components that ignore params. The fix is obvious once you see it.
TL;DR
The /en pages rendered Indonesian because four page components were synchronous and returned hardcoded locale content, so they never touched params at all. The fix: make each page async, await params, map locales to content, and guard unknown locales with hasLocale plus a 404. Green builds only prove components render, not that locale content is correct.
I opened /en/about that afternoon, refreshed once more, and the text was still in Indonesian. Even though the aboutEn file was already in the repo, translated neatly. Switched to /en/client, same thing. /en/diklat didn't move either. No errors in the browser, build was green across the board. It felt like the EN content was just for show.
My initial assumption was simple. App Router already understands the locale from the [locale] folder, so pages inside it automatically render the right content. If the EN files exist and the build passes, content parity is safe. Both wrong.
I checked app/[locale]/about/page.tsx. The component was still synchronous.
// app/[locale]/about/page.tsx - before fix
export default function AboutPage() {
return <About content={about} />
}No params at all. Four pages had the same pattern: about, client, diklat, service. All of them returned the hardcoded id object. So whether you opened /en or /id, you got the same thing. And a successful build doesn't check this logic. It only confirms the component can render, not that the content is correct per locale.
Params is now a Promise, not a plain object
Since Next.js 15, params in pages and layouts is a Promise and must be awaited [2]. Not just params either. searchParams, cookies(), headers(), draftMode() all went async. There's a codemod for the migration, plus a temporary sync compatibility mode that only warns in dev [2].
This is a breaking change that's easy to miss if your components never touched params in the first place. TypeScript can actually flag synchronous components accessing params as an error. But if you don't access params at all, like my case, nothing complains. Silent bug.
The official i18n pattern makes it obvious: nest under app/[lang], grab { lang } = await params in the page, then guard with hasLocale(lang) and notFound() for unknown locales [1]. The dictionary is simple, a map from locale to content object [1]. No magic.
The fix I went with: async + map + guard
To make each page read the locale from the route, I converted to async and built a content map.
// app/[locale]/about/page.tsx - after fix (simplified)
import { hasLocale } from '@/lib/i18n'
import { notFound } from 'next/navigation'
import { about, aboutEn } from '@/content/about'
const contentByLocale = { id: about, en: aboutEn }
export async function generateMetadata({ params }) {
const { locale } = await params
// use locale for per-language title/description
}
export default async function AboutPage({ params }) {
const { locale } = await params
if (!hasLocale(locale)) notFound()
return <About content={contentByLocale[locale]} />
}I repeated this pattern for all four pages in commit d1bfef9. generateMetadata also awaits params so metadata doesn't come out in the wrong language.
The hasLocale guard matters a lot. Without it, a weird locale like /xx/about still renders with the id fallback and looks normal, when it should 404. I prefer an explicit 404 over a silent fallback. The fallback is exactly what made yesterday's bug invisible to the build.
One nuance I only learned from the docs: the guard doesn't have to live in the page. You can move it into a dictionary util using next/root-params getters [1]. And the official example actually puts generateStaticParams in app/[lang]/layout.tsx, not in each page [1].
generateStaticParams isn't decoration
One more thing I got wrong: I assumed App Router is always SSG. No.
generateStaticParams can go in a page, layout, or route handler to prerender locale routes at build time, and it runs before Layout or Pages are generated [3]. But it's conditional. If you don't define it, or return an empty array without dynamic = 'force-static', the route renders on-demand [3]. So without generateStaticParams, /en/about still opens, it just isn't prerendered.
For this project, the cleanest option was generateStaticParams once in the root layout app/[locale]/layout.tsx, returning [{ locale: 'id' }, { locale: 'en' }]. No need to repeat it per page. That's what the docs show [1][3]. Principle: the layout knows the list of valid locales, pages just consume.
My opinion here is firm. Don't rely on the default locale falling back inside the component. Let TypeScript scream if a component is still synchronous and tries to access params without await. Better a failed local build from a type error than a prod that looks successful while /en still shows Indonesian. Locale content bugs are the hardest to spot if you only watch build status.
If you're migrating to Next.js 15, audit every page under [locale] first. Run tsc without the sync compatibility mode and see what's still synchronous. Then actually open /en/about, don't just check /about. If the text doesn't change, you know where to look.