next.js: Global 404 page is not available for internationalization

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
      Platform: win32
      Arch: x64
      Version: Windows 10 Pro
    Binaries:
      Node: 20.2.0
      npm: N/A
      Yarn: N/A
      pnpm: N/A
    Relevant packages:
      next: 13.4.5-canary.3
      eslint-config-next: N/A
      react: 18.2.0
      react-dom: 18.2.0
      typescript: 4.9.4

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true), Internationalization (i18n), Routing (next/router, next/navigation, next/link)

Link to the code that reproduces this issue or a replay of the bug

https://github.com/Arctomachine/404-page-internationalization-root-layout

To Reproduce

  1. Create project as usually, start dev server
  2. Follow internationalization section guide and create /app/[lang] folder. Create layout file (starting from <html lang={lang}>) that will act as root layout.
  3. Create /app/not-found.tsx file according to not found section
  4. Follow console instruction and make top level root layout (containing only `<>{children}</>) for this page to start working
  5. Stop dev server. Run build and then start scripts. Visit any not existing url that would trigger 404

Describe the Bug

There are multiple problems with it. One of them being actual bug, the rest just would make this the (currently) only solution bad - if it worked.

  1. 404 page contents blink for a second, then page goes completely white and empty. Numerous errors in browser console.
  2. This approach makes it impossible to fit error page into acting root layout under /[lang]
  3. This approach makes it impossible to translate error page into other languages

Uploaded reproduction: https://404-page-internationalization-root-layout.vercel.app/en

Expected Behavior

Since there is not much to be expected from this solution, I will propose new expected workflow instead. A new way of making 404 page with internationalization in mind:

  • treat the topmost available layout as root. Then not-found file inside of it will behave like global 404 page
  • allow passing props into not-found file to change its contents based on language of currently visited page

Which browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

any

About this issue

  • Original URL
  • State: open
  • Created a year ago
  • Reactions: 34
  • Comments: 24 (5 by maintainers)

Most upvoted comments

The point is that a 404 page is about as “static” a page as you could possibly ever want to create. Localising a static 404 page should be dead easy in any web framework in 2024.

The sad truth is that NextJs has neglected and botched internationalisation for 8 years now – users should not expect anything new.

@huozhi In my project, the not-found.tsx file is located in the app/[lng] directory, and the 404 page does not take effect.

@huozhi the solution you describe is not for issue in topic. It works for calling notFound() function, yes. But the issue here is global 404 page. If file is placed into /[lang] folder, then default 404 page will be used instead of our file. image

@Arctomachine I encountered this issues as well today and here’s what I think is a working solution (until the logic works as listed in the expected behaviour above):

I got a middleware function that will always prepend a locale to the any URL requested (except for certain allow-listed assets):

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import Negotiator from "negotiator";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import { i18n } from "./i18n-config";

const ALLOW_LISTED_ASSETS = ["/icon.png", "/favicon.ico"];

function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headers
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages();
  const locales: string[] = i18n.locales;
  return matchLocale(languages, locales, i18n.defaultLocale);
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  if (ALLOW_LISTED_ASSETS.includes(pathname)) return;

  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    return NextResponse.redirect(
      new URL(`/${locale}/${pathname}`, request.url)
    );
  }
}

export const config = {
  matcher: ["/((?!studio|api|_next/static|_next/image).*)"],
};

This ensures that any URL will hit the /[lang] route.

My /app folder is organized like this:

/
  /[lang]
    /(some-group)
    /some-static-route
    /[...notFound]
      page.tsx
  not-found.tsx
  layout.tsx

As you can see, in the /[lang] dynamic segment, I only have static routes, which allows me to use the /[...notFound] handler with the following page:

import { notFound } from "next/navigation";

export default async function Page() {
  notFound();
}

This will ensure that the closest not-found.tsx file gets hit (ie. the one in /[lang]/not-found.tsx), which has access to the lang from its layout.

Obviously, this will only work if you don’t have another dynamic segment nested at the root of /[lang] as it would clash with /[lang]/[...notFound].

It also relies on any request always getting routed with a lang prefix.

Hope this helps!

But then it automatically turns whole site into dynamic rendering. For dynamic site this method might be just strange, but for static sites it is simply impossible solution.

It’s been 6 months this issue is open and we don’t have a hint of fix yet… My workaround was putting the not-found at the root of the app folder with an empty layout.

In order to avoid console errors for me what worked was creating an empty layout and wrapping the not-found in an html and body tag. But then the next challenge is having a localized 404. For that I used next/headers to get the accepted language header (similar technique we use in the middleware) This is the only was I found because things like location.href or other client methods wouldn’t work in production build…

The problem with this method is that if the user’s browser language is set to korean but was visiting the site in english then the 404 page will be displayed in korean.

app/layout.jsx

export const metadata = {
  title: '404',
  description: '404',
}

export default function Layout({ children }) {
  return (
    <>{children}</>
  )
}

app/not-found.jsx

import { headers } from 'next/headers'
import Negotiator from 'negotiator'
import { match } from '@formatjs/intl-localematcher'

const defaultLocale = 'kor'
const locales = ['kor', 'eng']

export default async function NotFound() {
  const languageHeaders = headers().get('accept-language')
  const languages = new Negotiator({ headers: {'accept-language': languageHeaders }}).languages()
  const locale = match(languages, locales, defaultLocale)

  const dict = {
    eng: 'this page is not found.',
    kor: '요청하신 페이지를 찾을 수 없습니다.',
  }

  return (
    <html>
      <body>
        <div className="section-error">
          {dict[locale]}
        </div>
      </body>
    </html>
  )
}

Using headers in your not-found file will make it dynamic and uncachable. When the not found is dynamic, everything in that folder will turn dynamic. So with this method, you are eliminating all caching of your site.

So should we just open new issue since this one does not look like it is ever going to be reopened and bug is not fixed?

Regarding to the reproduction, the approach using root layout that only rendering children and a root not-found that doesn’t contain html/body will break the client rendering if you directly hit a non-existen route. The root not found page is composed by root layout and root not-found, so the output html of not-found page is missing html and body that will lead to hydration errors later.

You can remove the root layout file which renders only children, and place the root not-found to app/[lang]/not-found.js

And not-found.js doesn’t work on layout group.