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.tsx for Pages Router and app/not-found.tsx for App Router.
  • Always export a default component: Named exports or missing exports trigger errorMissingDefaultExport and fall back to the built-in UI.
  • Never combine notFound and redirect: Data-fetching functions must return exclusively one or the other to avoid runtime errors in render.tsx.
  • Check for router conflicts: Coexisting pages/404 and app/not-found files can cause inconsistent behavior depending on which router handles the request.
  • Verify static exports: Ensure out/404.html contains your custom markup after running next export, as the framework maps /_not-found to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →