Custom 404 Page Next.js 14: Common Pitfalls Preventing Proper Rendering
The most common reasons a custom 404 page fails to render in Next.js 14 are incorrect file naming conventions between the Pages and App Routers, missing default exports, and returning both notFound and redirect from data-fetching functions.
Next.js 14 supports two distinct routing architectures: the legacy Pages Router (pages/…) and the modern App Router (app/…). Each system implements its own convention for handling 404 errors, and the framework selects which component to render through a series of internal validations defined in the vercel/next.js source code. If any validation fails, Next.js falls back to the built-in NotFound component located at packages/next/src/client/components/builtin/not-found.tsx instead of your custom implementation.
File Naming and Location Conventions
The framework discovers your custom 404 page using specific filename patterns that differ between routing systems.
Pages Router Convention
For the Pages Router, the file must be located at exactly pages/404.{js,jsx,ts,tsx}. The lookup logic in packages/next/src/server/lib/find-page-file.ts specifically searches for this pattern during the route discovery phase.
// File: pages/404.tsx
export default function CustomNotFound() {
return (
<main style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Page Not Found</h1>
<p>The requested resource does not exist.</p>
</main>
)
}
App Router Convention
The App Router uses a different naming scheme. You must create app/not-found.{js,jsx,ts,tsx} for route-specific 404 boundaries, or app/global-not-found.{js,jsx,ts,tsx} for a global fallback. The regex rootNotFoundFileRegex in find-page-file.ts only matches these specific filenames.
// File: app/not-found.tsx
export default function NotFound() {
return (
<section style={{ margin: '4rem' }}>
<h2>Resource Unavailable</h2>
<p>This route does not exist in the application.</p>
</section>
)
}
Export Requirements and Component Validation
Even with correct file placement, your custom 404 page must satisfy strict export validation rules.
Mandatory Default Export
The component tree builder in packages/next/src/server/app-render/create-component-tree.tsx validates that the module exports a default component. If the validation fails, the framework calls errorMissingDefaultExport(..., 'not-found') and discards your file, falling back to the built-in UI.
Incorrect implementation:
// This will fail validation
export const NotFound = () => <div>404</div>
Correct implementation:
// This satisfies the default export requirement
export default function NotFound() {
return <div>404</div>
}
Component Type Constraints
For the App Router, the not-found component can be a React Server Component, but it must return valid JSX. Exporting a plain object, class component without proper instantiation, or a client-only component without the use client directive causes a type mismatch that triggers the fallback mechanism.
Data Fetching Conflicts
A critical pitfall occurs when data-fetching functions return conflicting directives that short-circuit the rendering pipeline.
The notFound/redirect Exclusivity Rule
In packages/next/src/render.tsx (lines 917-925), the framework enforces that getStaticProps or getServerSideProps cannot return both notFound: true and a redirect object simultaneously. If both properties are present, Next.js throws an error and aborts the render, often resulting in the default error overlay instead of your custom 404 page.
Problematic code:
// File: pages/posts/[slug].tsx
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug)
if (!post) {
// This will cause an error!
return {
notFound: true,
redirect: { destination: '/archive', permanent: false }
}
}
return { props: { post } }
}
Correct approach:
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug)
if (!post) {
// Return only notFound
return { notFound: true }
}
return { props: { post } }
}
Router Coexistence and Precedence
When migrating from Pages Router to App Router, conflicting 404 implementations can cause unexpected behavior.
Precedence Rules
If both pages/404 and app/not-found exist simultaneously, the App Router takes precedence for routes rendered under the app directory. However, legacy Pages Router routes still use pages/404. This split behavior can confuse debugging efforts when you expect a unified 404 experience across the application.
Global vs. Route-Specific Fallbacks
The App Router supports app/global-not-found.tsx as a catch-all fallback when no specific not-found boundary exists for a route segment. According to packages/next/src/server/app-render/app-render.tsx, the framework builds a loader tree that prioritizes global-not-found over the built-in component, but only if the file exists and exports a valid default component.
Static Export Considerations
When using next export to generate a static site, the build process maps the special route /_not-found to the static file 404.html (as implemented in packages/next/src/export/index.ts, lines 862-866).
Verification Steps
After running the export command, verify that your custom markup appears in the generated file:
npm run build
npm run export
# Verify custom content exists
grep "Your custom 404 text" out/404.html
If the output shows the default Next.js 404 text instead of your custom content, the export step likely failed to detect your pages/404 or app/not-found file during the build phase.
Summary
- File naming must match the router convention exactly: Use
pages/404.tsxfor Pages Router andapp/not-found.tsxfor App Router. - Always export a default component: Named exports or missing exports trigger
errorMissingDefaultExportand fall back to the built-in UI. - Never combine
notFoundandredirect: Data-fetching functions must return exclusively one or the other to avoid runtime errors inrender.tsx. - Check for router conflicts: Coexisting
pages/404andapp/not-foundfiles can cause inconsistent behavior depending on which router handles the request. - Verify static exports: Ensure
out/404.htmlcontains your custom markup after runningnext export, as the framework maps/_not-foundto this file.
Frequently Asked Questions
Why does my custom 404 page show the default Next.js error UI instead of my component?
The framework validates your file against several rules in the source code. If your file is named incorrectly (e.g., 404.tsx in the app directory instead of not-found.tsx), lacks a default export, or fails module type validation in create-component-tree.tsx, Next.js falls back to the built-in NotFound component located at packages/next/src/client/components/builtin/not-found.tsx.
Can I use getStaticProps or getServerSideProps with a custom 404 page in the App Router?
No. The App Router uses app/not-found.tsx as a React Server Component or Client Component, and it does not support getStaticProps or getServerSideProps. Data fetching for error boundaries in the App Router should happen within the component itself using fetch or by passing props from parent layouts. The getStaticProps/getServerSideProps APIs are exclusive to the Pages Router.
What happens if I have both pages/404.tsx and app/not-found.tsx in the same project?
When both files exist, the App Router takes precedence for any routes served from the app directory, while the Pages Router continues to use pages/404.tsx for legacy pages. This can lead to inconsistent user experiences if the two components have different designs. To ensure uniformity, either migrate completely to the App Router and remove pages/404.tsx, or keep both files synchronized with identical markup.
Why does my static export show the default 404 page instead of my custom one?
During next export, the framework maps the internal route /_not-found to the output file 404.html (as defined in packages/next/src/export/index.ts lines 862-866). If your custom pages/404.tsx or app/not-found.tsx is not detected during the build phase—due to incorrect file naming, missing default export, or build errors—the export process falls back to the default built-in component. Always verify that out/404.html contains your custom markup after the export completes.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →