next.js icon indicating copy to clipboard operation
next.js copied to clipboard

Global 404 page is not available for internationalization

Open Arctomachine opened this issue 1 year ago β€’ 25 comments

Verify canary release

  • [X] 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

Arctomachine avatar Jun 02 '23 15:06 Arctomachine

Also just bumped into this, your proposal makes sense too!

It's actually quite horrible to get this working, since the extra layout file in the app directory messes up a lot of other stuff for me.

StampixSMO avatar Jun 08 '23 19:06 StampixSMO

It's a really annoying issue but one way to solve it is to set a header in your middleware file containing the language and then use headers() to read that header and set the lang.

This will only work if you have a completely dynamic site

Feels a bit more hacky but it allows you to use not-found and set a html lang.

middleware.ts

export const middleware = (request: NextRequest) => {
  const headers = new Headers(request.headers);
  headers.set("x-site-locale", locale);

  return NextResponse.next({ request: { headers } })
}

root layout.tsx

import headers from 'next/headers'

export default function RootLayout() {
  return <html lang={headers().get('x-site-locale') ?? 'en-US'}>{children}</html>;
}

blurrah avatar Jun 14 '23 19:06 blurrah

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.

Arctomachine avatar Jun 14 '23 22:06 Arctomachine

Fair enough, was too focused on dynamic pages, I'll edit the reply.

blurrah avatar Jun 15 '23 06:06 blurrah

@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!

simonwalsh avatar Jun 26 '23 20:06 simonwalsh

Update re: https://github.com/vercel/next.js/issues/50699#issuecomment-1608167519

After poking around, I realize I forgot that the not-found.tsx route doesn't accept any props. This means the only way to use the technique above is to turn that component into a client component and fetch the error page data (if needed) at runtime to be able to display the correct localized information. Not ideal at all :\

simonwalsh avatar Jun 26 '23 22:06 simonwalsh

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

kmvan avatar Jul 20 '23 14:07 kmvan

same issue.

kjxbyz avatar Aug 26 '23 07:08 kjxbyz

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

huozhi avatar Aug 28 '23 18:08 huozhi

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

kjxbyz avatar Aug 29 '23 00:08 kjxbyz

@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 avatar Sep 03 '23 20:09 Arctomachine

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?

Arctomachine avatar Sep 15 '23 11:09 Arctomachine

@huozhi Any update?

isaachinman avatar Dec 06 '23 14:12 isaachinman

Encountered this today, I am forced to move my RootLayout out of [locale] and then put a not-found at the root for it to work. But this should not be the way. the not-found at rootLayout level should be the app default 404 handler

Crocsx avatar Dec 11 '23 08:12 Crocsx

Yes, this is very workaroundy.

It gets even more interesting when you start adding error.js files. You won't use global-error.js file as it now loses its point (your RootLayout most likely doesn't include much code).

Instead, you'll be forced to handle errors of the layout below (the LocaleLayout) in some way if you want to display translated error page. In this case, I had to make my own ErrorBoundary file (Client Component) and use it in the layer right after the provider supplying translated messages (before other contexts).

Lots of workarounds and mess now in my code. I love the idea about the app router tho and I understand it is a lot of hard work to get this done properly.

MichalMoravik avatar Dec 11 '23 21:12 MichalMoravik

We load the translation in server component code and pass it to a provider which is a client component. Because the error page is a client component we cannot use our default handling.

cbratschi avatar Dec 12 '23 10:12 cbratschi

Any update?

Edit by maintainers: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote πŸ‘ on the issue description or subscribe to the issue for updates. Thanks!

erickcestari avatar Jan 16 '24 19:01 erickcestari

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>
  )
}

valentincognito avatar Jan 30 '24 01:01 valentincognito

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.

laurenskling avatar Feb 04 '24 08:02 laurenskling

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.

If that's the case they should put a big warning in nextjs's documentation. Because they are using headers in not-found.jsx

https://nextjs.org/docs/app/api-reference/file-conventions/not-found

valentincognito avatar Feb 05 '24 07:02 valentincognito

Dynamic pages aren't statically generated, but that doesn't mean they are not cached. They still have CDN caching at Vercel.

lhguerra avatar Feb 06 '24 00:02 lhguerra

Dynamic pages aren't statically generated, but that doesn't mean they are not cached. They still have CDN caching at Vercel.

I'm pretty sure using headers will make SSR run for every single request, because for ever user the headers can be different. Therefor, the page is not served from the cache. It will be slower to load than an static page.

laurenskling avatar Feb 06 '24 08:02 laurenskling

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.

isaachinman avatar Feb 06 '24 09:02 isaachinman

any updates on this?

Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote πŸ‘ on the issue description or subscribe to the issue for updates. Thanks!

DiegoGonzalezCruz avatar Mar 16 '24 20:03 DiegoGonzalezCruz

Any update on this thread?

Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote πŸ‘ on the issue description or subscribe to the issue for updates. Thanks!

richardtaylordawson avatar Jun 27 '24 20:06 richardtaylordawson