Skip to main content
All posts

next-intl Dev Log: Why Our English Blog Pages Returned 500

Every English blog page returned 500 because the root layout read the locale from headers. How we fixed it and made bad paths return 404.

IntentCode··12 min read

TL;DR

  • Every English blog page returned 500 because of one getLocale() call in the root layout.
  • We moved html lang to the [locale] layout so it comes from the URL, not request headers.
  • Unknown paths that crashed with 500 now get a 404 from a per-page locale guard.

What happened

The IntentCode website serves Korean and English with the Next.js App Router and next-intl. Korean pages have no prefix, and English pages live under /en. On October 21, 2026, a full scan of the site turned up a problem on the English side of the blog.

Every /en/blog/<slug> and /en/blog/tag/<tag> page returned a 500 error. That was all nine English blog URLs in the sitemap, and unknown English post URLs also returned 500 instead of 404. A search engine following the sitemap would have found nothing but error pages.

Two days later, on October 23, we found a second kind of 500. Paths that do not exist on our site, such as /administrator, /api-docs and /iconz, returned 500 instead of 404. The server log showed Cannot read properties of undefined (reading 'length') several times. The two bugs looked different, but both came from the same question: where does the app read the locale (the page language) from?

The first 500: a layout that read headers

Blog posts are read from the database, but the build step that produces our Docker image has no database. So post pages are not prerendered at build time. They use ISR (Incremental Static Regeneration): a static page is generated on the first request and regenerated every 60 seconds.

ts
// app/[locale]/blog/[slug]/page.tsx
export const revalidate = 60;      // regenerate every 60 seconds
export const dynamicParams = true; // build unknown paths on first request

export async function generateStaticParams() {
  // no DB at build time → empty list → every post is built on first request
}

The bug was in the root layout. In May 2026 we had changed it to call getLocale() so that English pages would stop declaring <html lang="ko">. Unless you tell it otherwise, next-intl reads the locale from a request header that its middleware adds. The next-intl docs note that reading headers opts the route into dynamic rendering.

Reading request headers inside an ISR page that must render statically made Next.js throw DYNAMIC_SERVER_USAGE. According to the Next.js docs, Next.js throws this error when a dynamic function such as headers is used while generating a static page, and if nothing catches it, rendering fails. A local production build without a database URL, the same as our Docker build, reproduced the error right away.

The fix: render html in the locale layout

We followed the structure next-intl recommends. The locale should come from the [locale] segment of the URL, not from headers. That means <html> has to be rendered by the [locale] layout instead of the root layout.

  • A new app/root-document.tsx holds <html> and <body>, fonts, global CSS and the company structured data.
  • app/[locale]/layout.tsx renders it with lang taken from the route params.
  • The admin area (app/admin/layout.tsx) and the 404 page are Korean only, so they render it with lang="ko".
  • The root app/layout.tsx keeps only site-wide metadata and returns children as is.
tsx
// app/[locale]/layout.tsx (simplified)
export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();
  setRequestLocale(locale); // render statically from params, not headers
  return <RootDocument lang={locale}>{children}</RootDocument>;
}

setRequestLocale lets next-intl learn the locale from params instead of headers. After the change, the same setup (a build with no database URL) returned 200 for English posts and tag pages, with lang="en". Unknown English post URLs now return 404. We also checked that each page renders exactly one <html> element.

The second 500: a matcher that only checked prefixes

The second bug started in the middleware matcher, the rule that decides which paths the middleware runs on. To skip locale handling for the admin area, the API and icons, the matcher excluded them with a regular expression. But the exclusion only compared the start of the path, as in admin|api|icon.

So /administrator skipped locale handling just because it starts with "admin". It went straight into app/[locale], where the locale became 'administrator'. The layout saw this and called notFound(), but in what we observed, the layout and the page rendered at the same time, so the page kept rendering. The home page read highlights[locale].length, hit undefined and ended in a 500.

The fix: a locale guard on every page

The obvious idea was to fix the matcher regex. But that would also change how working paths are routed, such as /icon, /apple-icon and the share image routes. So we left the routing alone and stopped bad locales at the page level instead.

ts
// i18n/require-locale.ts
export function requireLocale(locale: string): void {
  if (!hasLocale(routing.locales, locale)) notFound();
}

All 11 pages under [locale] call this right after await params. We added it to generateMetadata as well as the page body, because building metadata can crash for the same reason.

We checked nine bad paths against a production build. All of them, including two-segment paths like /administrator/blog, showed the 404 page. Valid paths such as home, blog, products, admin login, icons, share images and the sitemap still returned 200. The server error log had zero lines during the test.

What we changed so it does not happen again

We wrote down a few rules after this. The main one is read the locale from the URL, never from headers.

  • Layouts and pages use the locale from params instead of getLocale(), and call setRequestLocale first.
  • [locale] pages check the locale themselves instead of relying on the layout's check.
  • We test under production conditions. For us, that means building without a database URL.
  • We keep a list of both valid and invalid paths, so after a fix we can confirm the 200s and 404s in one pass.

Neither bug showed up clearly on the dev server. Dev mode renders pages on every request, so static rendering limits stay hidden. If you run a multilingual site, we suggest a habit: open a few English URLs on a production build with production-like environment variables.

References

Get new posts by email

Notes from building our products, about twice a week. Questions? Ask Dot.