Next.js Quirks: Internal Route Relations
Have you ever wondered why your debug logs show that Next.js renders your not-found.tsx, despite you receiving the correct page.tsx? Or maybe you have adopted Cache Components and noticed that your page.tsx has way more cache tags attached to it than you anticipated? The reason is actually simple: Next.js renders not just the page, but other outcomes too (and passes them to the client as fallbacks).
Imagine you're halfway through rendering a heavy page.tsx. Seconds have already passed! Suddenly you find out that the requested content is gone and you now need to display the not-found page. To avoid delaying the result any further, Next.js has already rendered (or begun rendering) the associated not-found.tsx page. This speculative rendering also applies to the newly added forbidden.tsx and unauthorized.tsx.
If you're brave enough, you can check this out yourself by visiting the createComponentTreeInternal function in create-component-tree.tsx. The code is very complex, so I won't go into detail. I'll instead point you to one of the biggest smoking guns: L507-530.
const [notFoundElement, notFoundFilePath] = await createBoundaryConventionElement({
ctx,
conventionName: "not-found",
Component: NotFound,
styles: notFoundStyles,
tree,
});
const [forbiddenElement] = await createBoundaryConventionElement({
ctx,
conventionName: "forbidden",
Component: Forbidden,
styles: forbiddenStyles,
tree,
});
const [unauthorizedElement] = await createBoundaryConventionElement({
ctx,
conventionName: "unauthorized",
Component: Unauthorized,
styles: unauthorizedStyles,
tree,
});
AFAIK Next.js recursively walks the relevant parts of your app directory to produce the result for the request, so the createComponentTreeInternal function is called multiple times with different files.
At the start, I mentioned Cache Components. They're also affected by this mechanism. Next.js associates all tags used to produce a result with that specific render pass. If you're using a Cache Component in not-found.tsx, those tags are also associated with the corresponding page.tsx (and vice versa). Having a not-found.tsx with the tag foo high up in the app directory means all page.tsx files below it will have the tag foo attached. If you invalidate the tag foo (to update your not-found.tsx), all of those page.tsx files will also be marked as STALE, potentially causing unwanted computation. This is especially expensive if you have a lot of dynamic segments!
If you want to learn about another niche Next.js quirk that relates to the app directory, I have previously written about how not-found.tsx routes are actually scoped to their position in the app directory (and not the URL) and what this means for composition.