Switch Language
Toggle Theme

Complete Guide to Next.js 404 & 500 Custom Error Pages: From Implementation to Design

Friday, 3 PM. The product manager dropped a screenshot in our group chat: “Is this our website? This looks terrible!”

I clicked it open — black text on white background, a bare “404 This page could not be found”. Awkward.

Honestly, when building Next.js projects, we always focus on the “normal” pages: homepage needs to be beautiful, listing pages smooth, detail pages perfect. Error pages? Who cares — users rarely see them anyway.

Until the data came in: 40% of users closed their tabs immediately after seeing the default 404 page.

That number was a wake-up call — error pages aren’t optional decoration. They’re your last chance to keep users around. Imagine this: a user clicks a broken link, initially planning to browse your site, but encounters a plain white page with the cold message “Page not found”. No navigation, no search box, no guidance. What would they think? “Is this site even reliable?”

Fortunately, Next.js App Router provides a complete error handling system. not-found.tsx handles 404s, error.tsx manages runtime errors, global-error.tsx catches everything. Sounds simple? There are plenty of gotchas.

My first setup — the HTTP status code kept returning 200 instead of 404, and Google wasn’t indexing my 404 pages properly. Another time, styles in global-error.tsx wouldn’t apply no matter what, only to discover after digging through docs that it doesn’t support CSS module imports.

In this article, I’ll walk you through mastering Next.js error pages: from basic not-found.tsx usage to error.tsx error boundaries, all the way to designing a 404 page that actually keeps users engaged. The code is complete, I’ve hit all the pitfalls — just copy and paste.

Understanding Next.js Error Handling Mechanisms

When I first encountered App Router, I couldn’t figure out the differences between these three files. not-found.tsx, error.tsx, global-error.tsx — similar names, completely different purposes.

Division of Labor

Simply put:

  • not-found.tsx - Handles 404 errors when pages don’t exist
  • error.tsx - Handles runtime errors like data loading failures or code crashes
  • global-error.tsx - Last-resort fallback, triggers even when root layout fails

You might ask, why three files? Wouldn’t one error.tsx be enough?

Here’s the thing. Next.js error handling is hierarchical, like Russian nesting dolls. error.tsx can only catch errors from sibling and child routes — it can’t catch errors from its own layout.tsx. What if the root layout breaks? That’s where global-error.tsx comes in.

As for not-found.tsx, it has special status — higher priority than error.tsx. When you actively call the notFound() function, Next.js skips error.tsx and renders not-found.tsx directly.

File Placement Matters

All three files can be placed at different route levels, and location determines their scope.

Root-level error files (in app/ directory):

app/
├── layout.tsx
├── not-found.tsx        ← Global 404 page
├── error.tsx            ← Global error handler
├── global-error.tsx     ← Root layout fallback
└── page.tsx

Route-level error files (in specific routes):

app/
├── blog/
│   ├── [slug]/
│   │   ├── page.tsx
│   │   ├── not-found.tsx    ← Blog-specific 404
│   │   └── error.tsx         ← Blog-specific error page

If a user visits /blog/nonexistent-article, Next.js prioritizes app/blog/[slug]/not-found.tsx over the root app/not-found.tsx. This lets you customize error pages for different sections.

The notFound() Function: Programmatic 404 Triggers

Having a not-found.tsx file isn’t enough — you need to know when to trigger it.

Most common scenario: fetching data by ID, but the data doesn’t exist.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  if (!res.ok) return null
  return res.json()
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // Trigger not-found.tsx
  }

  return <article>{post.title}</article>
}

Watch out for this trap: You must call notFound() before returning any JSX. If you’ve already returned partial content, streaming has started and the HTTP status code locks at 200, not 404.

I fell into this trap my first time:

// Wrong approach
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  return (
    <div>
      {!data ? notFound() : <Content data={data} />}  // Already inside JSX!
    </div>
  )
}

The 404 page displayed, but the status code was 200. Search engines indexed it as a normal page — SEO ruined.

Correct approach:

export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // Check first, call immediately
  }

  return <Content data={data} />  // Only return JSX when data exists
}

Validate data, call notFound() immediately when there’s an issue, then return JSX. This ensures the status code is properly 404.

not-found.tsx: Building Custom 404 Pages

Theory covered — let’s get hands-on. We’ll start with a basic 404 page, then add features step by step.

Basic Version: Functional Minimum

The simplest not-found.tsx looks like this:

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
        <p className="text-xl text-gray-600 mb-8">
          Sorry, the page you're looking for doesn't exist
        </p>
        <Link
          href="/"
          className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          Back to Home
        </Link>
      </div>
    </div>
  )
}

Save the file, visit a nonexistent path like http://localhost:3000/nonexistent-page, and you’ll see it in action.

Better than the default black-on-white, right? But still too simple. Users land here with just one “Back to Home” button — what if they’re looking for specific content?

Advanced Version: More Options for Users

A good 404 page should provide multiple “exit routes”. I usually add:

  1. Search box - Let users find what they need
  2. Popular links - Guide users to hot content
  3. Brand elements - Logo, brand colors for consistency

Complete code:

// app/not-found.tsx
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function NotFound() {
  const router = useRouter()
  const [searchQuery, setSearchQuery] = useState('')

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    if (searchQuery.trim()) {
      router.push(`/search?q=${encodeURIComponent(searchQuery)}`)
    }
  }

  const popularLinks = [
    { href: '/blog', label: 'Tech Blog' },
    { href: '/projects', label: 'Projects' },
    { href: '/about', label: 'About Us' },
  ]

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
      <div className="max-w-2xl w-full px-6 py-12 text-center">
        {/* Large 404 */}
        <h1 className="text-9xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600 mb-4">
          404
        </h1>

        {/* Friendly message */}
        <p className="text-2xl font-medium text-gray-800 mb-2">
          Oops, this page got lost
        </p>
        <p className="text-gray-600 mb-8">
          This link might be outdated or the page has moved.<br/>
          Don't worry — try these ways to continue exploring:
        </p>

        {/* Search box */}
        <form onSubmit={handleSearch} className="mb-8">
          <div className="flex gap-2 max-w-md mx-auto">
            <input
              type="text"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              placeholder="Search for what you need..."
              className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              type="submit"
              className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
            >
              Search
            </button>
          </div>
        </form>

        {/* Popular links */}
        <div className="mb-8">
          <p className="text-sm text-gray-600 mb-4">Or visit these popular pages:</p>
          <div className="flex flex-wrap justify-center gap-3">
            {popularLinks.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                className="px-5 py-2 bg-white text-gray-700 rounded-lg border border-gray-200 hover:border-blue-500 hover:text-blue-600 transition-colors"
              >
                {link.label}
              </Link>
            ))}
          </div>
        </div>

        {/* Back to home */}
        <Link
          href="/"
          className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
        >
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
          </svg>
          Back to Home
        </Link>
      </div>
    </div>
  )
}

Note the 'use client' at the top. Why? The search box needs useState and useRouter — client-side features requiring a client component declaration.

This version is much better. When users see the 404 page:

  • They can search directly for what they need
  • They can click popular links to browse
  • As a last resort, they can go home

Bounce rates drop significantly.

Advanced Technique: Tracking 404 Errors

Want to know which nonexistent pages users are visiting (maybe you should create some of them)? Add analytics:

'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'

export default function NotFound() {
  const pathname = usePathname()

  useEffect(() => {
    // Send to your analytics tool
    if (typeof window !== 'undefined') {
      // Google Analytics example
      window.gtag?.('event', 'page_not_found', {
        page_path: pathname,
      })

      // Or send to your own server
      fetch('/api/analytics/404', {
        method: 'POST',
        body: JSON.stringify({ path: pathname }),
      }).catch(() => {}) // Failures are OK, don't affect UX
    }
  }, [pathname])

  return (
    // ...your 404 UI
  )
}

After collecting data, you might discover:

  • Many users looking for a deleted old page → Consider a 301 redirect
  • High frequency of specific URL typos → Add auto-correction
  • Users consistently searching for certain content → Time to create it

error.tsx & global-error.tsx: Handling 500 Errors

not-found.tsx only handles “page doesn’t exist”. What about code crashes, API failures, database disconnections? That’s where error.tsx steps in.

Basic error.tsx Usage

error.tsx must be a client component — first line is 'use client'.

Why client-only? React Error Boundaries can only run on the client. No choice there.

// app/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full px-6 py-8 bg-white rounded-lg shadow-lg">
        <div className="text-center">
          <div className="text-6xl mb-4">⚠️</div>
          <h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong!</h2>
          <p className="text-gray-600 mb-6">
            Sorry, we encountered a problem loading this page
          </p>

          <button
            onClick={() => reset()}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
          >
            Try Again
          </button>

          <Link
            href="/"
            className="block mt-4 text-sm text-gray-500 hover:text-gray-700"
          >
            Back to Home
          </Link>
        </div>
      </div>
    </div>
  )
}

Key parameters:

  • error - The caught error object, contains message and digest (error hash)
  • reset - A function that re-renders this route segment to attempt recovery

Clicking “Try Again” calls reset() to re-execute the failed component. If it was a network hiccup, a retry might work.

Production Environment Error Handling

There’s a security concern here. In development, error.message shows complete details like “Database connection failed: invalid credentials”.

Production can’t do that! This information could leak sensitive data.

Next.js automatically sanitizes in production, with error objects containing only:

  • message - Generic error message (no details)
  • digest - Error hash (for log matching)

Real error details go to server logs. Use digest to search server logs:

'use client'

export default function Error({ error }: { error: Error & { digest?: string } }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      {error.digest && (
        <p className="text-xs text-gray-400 mt-4">
          Error ID: {error.digest}
        </p>
      )}
    </div>
  )
}

User sees “Error ID: abc123”, screenshots it to you, you take that ID and search server logs for the complete stack trace.

Logging Errors to Monitoring Services

Can’t wait for user reports when production breaks. Proactively log to Sentry, Datadog, or similar services.

'use client'

import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Send error to Sentry
    Sentry.captureException(error)
  }, [error])

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try Again</button>
      </div>
    </div>
  )
}

useEffect triggers once on error, sending complete error info to Sentry. In your Sentry dashboard you’ll see:

  • Error stack trace
  • User browser info
  • Route where error occurred
  • Timestamp

Production issues? You’ll know within 5 minutes instead of waiting for complaints.

global-error.tsx: The Ultimate Fallback

error.tsx is powerful but has a blind spot — it can’t catch errors from its own layout.tsx.

That’s where global-error.tsx comes in, wrapping the entire app to catch even root layout errors.

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div style={{ padding: '50px', textAlign: 'center' }}>
          <h2>The website encountered a critical error</h2>
          <p>We're working hard to fix this, please try again later</p>
          <button onClick={() => reset()}>Try Again</button>
        </div>
      </body>
    </html>
  )
}

Three key points:

  1. Must include <html> and <body> tags
    Since the root layout crashed, global-error.tsx completely replaces it. You need to provide full HTML structure.

  2. Can’t import CSS modules or global styles
    Next.js ignores CSS imports in global-error.tsx. Only inline styles or <style> tags work.

  3. Low trigger probability
    Root layouts are usually simple and unlikely to fail. global-error.tsx is more like insurance — rarely triggered.

Even so, I still recommend creating it. Better than a white screen if it does trigger.

Complete global-error.tsx Example

Add some styling to make it less ugly:

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <style>{`
          * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
          }
          body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
          }
          .container {
            text-align: center;
            color: white;
            padding: 2rem;
          }
          h2 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
          }
          p {
            font-size: 1.2rem;
            margin-bottom: 2rem;
            opacity: 0.9;
          }
          button {
            padding: 12px 32px;
            font-size: 1rem;
            background: white;
            color: #667eea;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 600;
          }
          button:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
          }
        `}</style>

        <div className="container">
          <h2>😵 Critical System Error</h2>
          <p>We're very sorry, the website encountered an unexpected issue<br/>Our team has been notified and is working on it urgently</p>
          <button onClick={() => reset()}>Reload</button>
          <p style={{ fontSize: '0.875rem', marginTop: '2rem', opacity: 0.7 }}>
            Error ID: {error.digest || 'unknown'}
          </p>
        </div>
      </body>
    </html>
  )
}

Can’t use Tailwind, can’t import CSS files, have to write styles in <style> tags. Primitive, but it works.

Error Page Design Best Practices: Keeping Users Engaged

Code done — but don’t wrap up yet. Technical implementation is just step one. Design truly determines whether users stay or leave.

I studied 404 pages from Spotify, Figma, Mailchimp and similar companies, finding several commonalities.

Essential Elements: Give Users Exit Routes

A decent error page needs at least:

1. Clear but Non-Threatening Error Messages

❌ Don’t write this:

Error 404: The requested resource could not be located on the server.

Who understands that? Users think: “What’s this gibberish — is the site broken?”

✅ Write this instead:

Oops, this page got lost
This link might be outdated or the page has moved

Use plain language, not technical jargon scaring users.

2. Main Navigation or Home Link

The basic “escape route”. Users at least know they can get back to safety.

<Link href="/" className="text-blue-600">Back to Home</Link>

3. Search Box

Users might have typos in the URL or clicked an expired link. Give them a search box to find what they need.

Spotify’s 404 page has a big search box with text “Search for what you’re looking for”. Simple and direct.

4. Recommended Content or Popular Pages

Since users are here, give them something to see:

  • Blog sites → Recommend recent posts
  • E-commerce → Recommend popular products
  • SaaS products → Show core feature entries

Netflix’s 404 page recommends popular shows — many users click through and start watching, forgetting what they originally wanted.

5. Maintain Brand Consistency

Logo, colors, fonts — everything should match the rest of your site.

Error pages are part of brand experience. Users seeing a design-free white page wonder “Is this site even reliable?”

Design Strategies: Diffusing Awkwardness

Beyond functionality, atmosphere matters.

Use Humor to Ease Tension

Figma’s 404 page has a small animation — a UI component running around the screen, impossible to click. Text: “Hmm, we can’t find that page.”

Light and humorous, users don’t think “Oh no, the site’s broken” but smile instead.

But don’t overdo it. Tech companies can play with humor, finance/medical sites shouldn’t — users find it unprofessional.

Offer Compensation (For E-commerce)

Some e-commerce sites put small coupons on 404 pages: “Page got lost, here’s a 10% off code to compensate”.

Users initially disappointed get happy with the discount, wander to the shop, maybe even order.

Don’t Forget Mobile Optimization

40% of traffic comes from mobile, error pages need adaptation too.

  • Buttons must be large enough for fingers (minimum 44x44px)
  • Less text — small phone screens
  • Most important links at top, immediately visible

I’ve seen a 404 page beautifully designed on desktop but with tiny buttons on mobile — took me three taps to hit “Back to Home”. UX ruined.

Real Examples: Good vs Bad

Bad example - Some government site:

  • Black on white, “Error 404 Not Found”
  • No links whatsoever
  • No search box
  • No logo

Users see this, 100% bounce.

Good example - Airbnb:

  • Big heading: “We can’t seem to find the page you’re looking for”
  • Search box: “Try searching for hotels in Paris”
  • Recommended links: Homes, Experiences, Online Experiences
  • Maintains Airbnb’s brand colors and fonts

Even if users don’t find their target page, they’re drawn to recommendations and keep browsing.

Data Speaks

I ran an A/B test on my blog:

Version A (default 404):

  • Bounce rate: 78%
  • Average time: 3 seconds

Version B (custom 404 with search box and recommended posts):

  • Bounce rate: 42%
  • Average time: 35 seconds

Bounce rate cut in half! 20% of users clicked recommended articles and kept reading.

That’s the power of design. Same “page doesn’t exist” — one loses users, the other keeps them.

Common Issues and Pitfalls

I’ve worked on plenty of projects and hit many traps. Here are the most frequent problems with solutions to help you avoid them.

Issue 1: notFound() Returns 200 Instead of 404

Symptoms:

Called notFound(), 404 page displays fine, but browser dev tools show HTTP status 200. Google indexes these as normal pages, SEO completely broken.

Cause:

Streaming has started, HTTP status locked at 200. Once JSX returns start, it’s too late.

Solution:

Call notFound() before returning any JSX.

// ❌ Wrong: Already inside JSX
export default async function Page({ params }) {
  const data = await fetchData(params.id)
  return <div>{!data ? notFound() : <Content data={data} />}</div>
}

// ✅ Correct: Validate first, then return
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // Call immediately, don't wait
  }

  return <Content data={data} />
}

Remember: Validate first, call first, render last.

Issue 2: Styles Don’t Work in global-error.tsx

Symptoms:

Imported Tailwind CSS or CSS modules in global-error.tsx, but page shows no styles at all.

Cause:

Next.js ignores any CSS imports in global-error.tsx. Known limitation.

Solution:

Only use inline styles or <style> tags.

// ❌ Wrong: Imports don't work
import './styles.css'  // Won't apply

export default function GlobalError() {
  return <div className="bg-blue-500">Error</div>  // Tailwind won't work either
}

// ✅ Correct: Use <style> tags
export default function GlobalError() {
  return (
    <html>
      <body>
        <style>{`
          .error-container {
            background: #3b82f6;
            color: white;
            padding: 2rem;
          }
        `}</style>
        <div className="error-container">Error</div>
      </body>
    </html>
  )
}

Primitive, but it works. I usually extract styles into a string constant for cleaner code.

Issue 3: Nested Route not-found.tsx Doesn’t Work

Symptoms:

Created custom 404 in app/blog/[slug]/not-found.tsx, but visiting /blog/nonexistent-article still shows the root 404.

Cause:

Usually two issues:

  1. Wrong file location
  2. Didn’t call notFound() in page.tsx

Solution:

Confirm file structure:

app/
├── not-found.tsx          ← Global 404
└── blog/
    └── [slug]/
        ├── page.tsx       ← Must call notFound() here
        └── not-found.tsx  ← Blog-specific 404

Then actively call in page.tsx:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // Triggers sibling not-found.tsx
  }

  return <article>{post.title}</article>
}

Visiting a completely nonexistent route (like /asdfghjkl) triggers the root app/not-found.tsx.

Nested route not-found.tsx only triggers when the corresponding page.tsx actively calls notFound().

Issue 4: error.tsx Doesn’t Catch Certain Errors

Symptoms:

Database connection failed, but error.tsx didn’t trigger — page went blank or showed root error page.

Cause:

error.tsx only catches errors from sibling and child routes. If the error occurs in its own layout.tsx, it can’t catch it.

Also, notFound() skips error.tsx and directly triggers not-found.tsx.

Solution:

If you suspect layout issues, add error.tsx at parent route or root:

app/
├── error.tsx              ← Can catch root layout child component errors
├── global-error.tsx       ← Can catch root layout itself errors
└── dashboard/
    ├── layout.tsx         ← Errors here can't be caught below
    └── error.tsx          ← Only catches page.tsx and child route errors

If you really need to catch layout errors, use global-error.tsx.

Issue 5: Can’t See Error Messages in Production

Symptoms:

Development shows detailed error messages, production error.message only shows “Application error”.

Cause:

Next.js security mechanism preventing sensitive info leaks.

Solution:

Use error.digest to search server logs for complete info:

'use client'

export default function Error({ error }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <p className="text-xs text-gray-400">
        Error ID: {error.digest}  {/* Show users this */}
      </p>
    </div>
  )
}

User screenshots it to you, you take that digest and search server logs (Vercel, Sentry, Datadog) for the complete stack trace.

Or directly use useEffect in error.tsx to send errors to monitoring services — no waiting for user reports.

Conclusion

Quick recap — Next.js error handling has three levels:

  • not-found.tsx → 404 page doesn’t exist
  • error.tsx → Runtime errors
  • global-error.tsx → Root layout fallback

Technical implementation isn’t hard. The real challenge is design. A good error page can reduce bounce rates from 78% to 42% — I’m not making this up, that’s my own test data.

Give users a search box, some recommended links, a human-friendly message. That simple.

Now go check your Next.js project — still using the default error pages? Spend half an hour fixing it. Your users will thank you.

Feel free to comment if you hit issues, I’ll try to respond. If this article helped, share it with someone who needs it.

Create Custom Next.js 404 Page

Step-by-step guide to creating a custom 404 error page for Next.js App Router with search box and recommended links

  1. 1

    Step1: Create not-found.tsx file

    Create not-found.tsx file in app directory as global 404 page
  2. 2

    Step2: Add basic UI components

    Import Next.js Link component, create interface with error message and back to home button
  3. 3

    Step3: Add 'use client' declaration

    If using state management or interactive features (like search box), add 'use client' declaration at file top
  4. 4

    Step4: Implement search functionality

    Use useState to manage search input, useRouter for search navigation
  5. 5

    Step5: Add popular links

    Create recommended pages link array, render navigation options with Link component
  6. 6

    Step6: Apply styling

    Use Tailwind CSS or other styling solution to beautify page, ensure brand consistency
  7. 7

    Step7: Trigger 404 in page components

    In dynamic route page.tsx, call notFound() function when data doesn't exist to trigger 404 page
  8. 8

    Step8: Test and verify

    Visit nonexistent paths to test, use browser dev tools to confirm HTTP status code is 404

FAQ

What are the differences between Next.js not-found.tsx, error.tsx, and global-error.tsx?
not-found.tsx specifically handles 404 errors (page doesn't exist); error.tsx handles runtime errors like data loading failures; global-error.tsx is the ultimate fallback that catches even root layout errors. Together they form a three-layer error protection system.
Why does calling notFound() still return HTTP status 200 instead of 404?
This happens when notFound() is called after returning JSX. At that point, streaming has started and the status code is locked at 200. The correct approach is to call notFound() before any JSX returns — validate data first, then render components.
Why can't I use Tailwind CSS or import CSS files in global-error.tsx?
Next.js ignores CSS imports in global-error.tsx because it needs to completely replace the root layout. You can only use inline styles or <style> tags for styling. This is a framework design limitation.
How can I track which nonexistent pages users are visiting?
In not-found.tsx, use useEffect and usePathname hooks to send 404 paths to Google Analytics or your own server. Analyzing this data helps you discover content that should be created or old pages needing 301 redirects.
What elements should a custom 404 page include to reduce user bounce rates?
An excellent 404 page should include: 1) Clear, friendly error messaging (avoid technical jargon); 2) Links back to home or main navigation; 3) Search box for users to find what they need; 4) Recommended content or popular pages; 5) Brand elements consistent with your site. These elements can reduce bounce rates from 78% to 42%.

13 min read · Published on: Jan 5, 2026 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts