Next.js Quirks: Not Found are scoped to routes
One of the more annoying quirks of the App Directory is that not-found.tsx are scoped to the current route.
Let me explain: Let's say we have a website that requires i18n. Our setup looks like this:
src
app
[locale]
layout.tsx
[[...slug]]
page.tsx
We want to show a not-found page in the relevant language. We browse the documentation for not-found.js and quickly realize that we can't just place the not-found.tsx under [locale]/[[...slug]], because it does not receive any dynamic segments (i.e. locale) like a page.tsx would. What now?
Hitting a dead end
Okay, no big deal. We want things to be static, so we just create dedicated routes for every locale and throw a not-found.tsx in each. Our modified setup would look like this:
src
app
[locale]
layout.tsx
[[...slug]]
page.tsx
en
layout.tsx
not-found.tsx
de
layout.tsx
not-found.tsx
fr
layout.tsx
not-found.tsx
Our assumption is that 404 pages would be chosen based on how the URL matches with the paths in the app directory. At least, that is how it works for page.tsx. Example: If we add src/app/en/foo/page.tsx, we could visit /en/foo.
This is a great idea, however not-found.tsx does not work like that. If we test our 404 page, we will see the default Next.js 404 page, not our cool custom not-found.
This is because we hit /[locale]/[[...slug]]/page.tsx, which calls notFound(). In simplified terms, Next.js now walks up from that route and looks for the closest not-found.tsx.
So it considers the following:
/[locale]/[[...slug]]/not-found.tsx
/[locale]/not-found.tsx
/not-found.tsx
We have none of those, so we get the default 404 page. None of our alternative routes are considered.
Potential solutions
There are two potential fixes. I'll start with the rather basic one.
Checking headers
If we don't care about making the request dynamic, we can check the headers() for the preferred language of the browser:
const acceptLanguage = (await headers()).get("accept-language");
const locales = acceptLanguage ? acceptLanguage.split(",") : ["en"];
This way we can move our not-found.tsx under [locale] (or top level, if we can repeat the layout code).
src
app
[locale]
layout.tsx
not-found.tsx
[[...slug]]
page.tsx
This is simple & reliable, however it does mean each request will be dynamic. We will need to make sure calls to our CMS/DB are cached correctly, not-found.tsx usually sees a lot of traffic. Another gotcha to consider is that there can be a mismatch between URL and what is shown. Example: If a user with a German browser visits /en/something/that/does/not/exist, they will get a German response coming from an English URL. Odd, but not a deal breaker.
Rotating our setup
If we want our routes to stay static, we can modify our app directory like this:
src
app
en
layout.tsx
not-found.tsx
[[...slug]]
page.tsx
de
layout.tsx
not-found.tsx
[[...slug]]
page.tsx
fr
layout.tsx
not-found.tsx
[[...slug]]
page.tsx
Now each locale has a layout.tsx, dynamic [[...slug]] segment and a not-found.tsx. We can now set up each not-found.tsx to be exactly what that language needs.
The obvious downside is that if we have many locales, this becomes annoying to set up and maintain, however we should be able to move all of the logic that would usually sit directly in our page.tsx etc. into another folder, so each of these repeating files just calls a function that gets a locale passed in.