EmpireUI
Get Pro
← Blog8 min read#error page#404#500

Error Page Design in React: 404, 500, Maintenance Mode

Build polished 404, 500, and maintenance-mode pages in React and Next.js — with real component code, design patterns, and UI tips that don't look like defaults.

broken screen displaying error code on dark background

Why Error Pages Actually Matter

Nobody plans to land on your 404 page. That's exactly the problem. Most teams ship a default 'Page Not Found' in week one and never touch it again — and then wonder why bounce rates spike the moment a stale link hits Twitter. Your error pages are load-bearing UX. They're often the *last* impression before a user leaves forever.

In practice, a well-designed error page can recover a surprising percentage of confused visitors. Studies from 2024 consistently showed that 404 pages with a working search bar or clear navigation CTA retained 20–30% of users who'd otherwise bounce. That's real retention you're leaving on the table.

Honestly, the bar is embarrassingly low here. Most error pages are white backgrounds, a sad 404 in 48px system font, and a single link back to home. If you ship anything with actual personality and a working action, you're already in the top 10% of the web.

This guide covers three distinct error states — 404 (not found), 500 (server crash), and maintenance mode — because they each need different copy, different UX affordances, and slightly different implementation patterns in React and Next.js.

404 Page: Not Found

The 404 is the most common error state and the one with the most creative room. Your goal is twofold: acknowledge the problem clearly, then give the user at least two ways out. Don't just dump them at a dead end.

In Next.js 13+ with the App Router, you create a not-found.tsx file at any route segment level. The top-level app/not-found.tsx is your global 404. Next.js automatically returns a real HTTP 404 status code from this file — you don't need to do anything special for that.

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

export default function NotFound() {
  return (
    <main className="min-h-screen bg-gray-950 flex flex-col items-center justify-center text-white px-4">
      <p className="text-violet-400 text-sm font-mono tracking-widest mb-4">404</p>
      <h1 className="text-4xl font-bold mb-3">Page not found</h1>
      <p className="text-gray-400 text-center max-w-sm mb-8">
        This URL doesn't exist. Maybe it moved, maybe it never existed —
        either way, it's not here.
      </p>
      <div className="flex gap-4">
        <Link
          href="/"
          className="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 rounded-lg text-sm font-medium transition-colors"
        >
          Go home
        </Link>
        <Link
          href="/blog"
          className="px-5 py-2.5 border border-white/10 hover:border-white/30 rounded-lg text-sm font-medium transition-colors"
        >
          Browse articles
        </Link>
      </div>
    </main>
  );
}

Worth noting: you can also trigger a 404 programmatically inside any Server Component by calling notFound() from next/navigation. This is useful when a dynamic route like /blog/[slug] resolves but the slug doesn't exist in your database — return notFound() and Next.js renders your not-found.tsx automatically.

For styling, dark backgrounds tend to work better here than light ones. They read as intentional rather than broken. If you want to push the visual further, pair this with something from the Empire UI] library — a subtle animated background or a glass card container makes your error page feel like part of your design system rather than an afterthought.

500 Page: Server Error

The 500 is different from a 404 in one critical way — it's *your* fault, not the user's. Your copy needs to reflect that. Don't say 'something went wrong on your end.' Say 'something broke on our side and we're on it.' Users are remarkably forgiving when you own the problem directly.

In Next.js App Router, the global error boundary lives in app/error.tsx. This file *must* be a Client Component (add 'use client' at the top) because it receives the error object as a prop and needs to call the reset function to retry rendering.

// app/error.tsx
'use client';

import { useEffect } from 'react';

interface ErrorPageProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function ErrorPage({ error, reset }: ErrorPageProps) {
  useEffect(() => {
    // Log to your error tracking service (Sentry, etc.)
    console.error('App error:', error);
  }, [error]);

  return (
    <main className="min-h-screen bg-gray-950 flex flex-col items-center justify-center text-white px-4">
      <p className="text-red-400 text-sm font-mono tracking-widest mb-4">500</p>
      <h1 className="text-4xl font-bold mb-3">Something broke on our side</h1>
      <p className="text-gray-400 text-center max-w-sm mb-8">
        We've been notified. Try again in a minute — it usually fixes itself.
      </p>
      <button
        onClick={reset}
        className="px-5 py-2.5 bg-red-600 hover:bg-red-500 rounded-lg text-sm font-medium transition-colors"
      >
        Try again
      </button>
      {error.digest && (
        <p className="mt-6 text-xs text-gray-600 font-mono">ref: {error.digest}</p>
      )}
    </main>
  );
}

That error.digest is a Next.js-generated hash that ties the client-side error back to the server log. Show it in your UI — it lets support staff look up the exact error without users needing to copy a full stack trace. Small detail, saves a lot of back-and-forth.

One more thing — if your error page itself crashes (rare but happens), you need a global-error.tsx at the root. This replaces the entire HTML document, so it must include <html> and <body> tags. Keep it dead simple: just a message and a reload button. No fancy styles, no external imports that might also be broken.

Maintenance Mode: The Planned Outage

Maintenance mode is a fundamentally different problem. It's not an error — it's intentional. And that means you have time to make it look good. This is actually your best opportunity to build some brand goodwill during a downtime window.

The implementation approach depends on where you want to intercept the request. For Next.js, Next.js Middleware (in middleware.ts) is the cleanest option — it runs at the edge, before any rendering, and you can redirect every route to /maintenance with a single environment variable toggle.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const MAINTENANCE_MODE = process.env.NEXT_PUBLIC_MAINTENANCE_MODE === 'true';
const ALLOWED_PATHS = ['/maintenance', '/api/health'];

export function middleware(request: NextRequest) {
  if (
    MAINTENANCE_MODE &&
    !ALLOWED_PATHS.some(path => request.nextUrl.pathname.startsWith(path))
  ) {
    return NextResponse.redirect(new URL('/maintenance', request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Flip NEXT_PUBLIC_MAINTENANCE_MODE=true in your deployment environment and every visitor lands on /maintenance — no code deploy needed. Flip it back when you're done. That's the entire workflow.

Quick aside: for your actual maintenance page design, this is the one error state where you can go full creative. Use an estimated time back. Show your status page URL. For a polished look, try a glassmorphism components card on a dark gradient — it reads as premium and intentional rather than broken. The glassmorphism generator will get you the exact CSS values in about 30 seconds.

Design Patterns That Work Across All Three

There are a handful of design decisions that apply to every error state regardless of which one you're building. Getting these right is what separates a polished error page from a default one.

First — status code placement. Put the number (404, 500, 503) in a small, monospace, muted font *above* the heading, not as the giant hero text. Giant 404s look dated as of about 2022. The status code is supporting information, not the main message. Your h1 should be plain English.

Second — give users exactly two CTAs, not one, not four. One primary action (go home, try again) and one secondary action (browse articles, contact support). More than two creates decision paralysis. Fewer than two leaves people with nowhere to go if the first option isn't right for them.

Third — always respect prefers-color-scheme. If your app has a dark mode, your error pages need one too. Nothing says 'we didn't think about this' like a blinding white error page in an otherwise dark app. Use your CSS custom properties or Tailwind's dark: variants consistently. The dark-mode-color-tokens post covers the token setup if you haven't done this yet.

That said, don't go wild with animations on error pages. A subtle fade-in on the content block is fine — anything that loops or demands attention is exhausting when someone is already frustrated. The page-transitions-nextjs patterns work well for entry animations if you want something polished without being distracting.

Integrating Error Tracking

A beautiful error page that doesn't tell you when it's shown is only half useful. Wire up error tracking so you know which URLs are 404ing, how often your 500 fires, and when maintenance mode gets hit unexpectedly.

For 500s, call your error tracking SDK inside the useEffect in error.tsx — Sentry, LogRocket, Datadog, whatever you're using. For 404s it's trickier, because not-found.tsx is a Server Component by default. You can convert it to a Client Component and fire a window.analytics.track() call, or log from a Server Action triggered on mount.

// app/not-found.tsx — with analytics
'use client';

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

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

  useEffect(() => {
    // Track 404 with the attempted path
    if (typeof window !== 'undefined' && window.analytics) {
      window.analytics.track('404 Viewed', { path: pathname });
    }
  }, [pathname]);

  // ... rest of your JSX
}

Look, most teams skip this step and then have zero visibility into whether their redirects are working or whether a bad deploy just 404'd half their sitemap. Spend the 15 minutes to wire it up. You'll thank yourself the next time you ship a route rename.

Taking Error Pages Further with Empire UI

If you want error pages that genuinely look like they belong in a well-designed product rather than a template, styling them with a cohesive design system makes the difference. Empire UI's component library gives you a starting point that's already visually opinionated — pick a style and your error pages will match your components automatically.

The cyberpunk and neobrutalism styles work particularly well for 404 pages — they carry an energy that makes the error state feel intentional rather than broken. For maintenance pages, glassmorphism on a dark background with a countdown timer reads as polished and planned. Browse the gradient generator to get a background that complements whichever style you choose.

You can also use Empire UI's MCP page to generate these components directly inside Cursor or Claude Code — describe your error page in plain English and get a fully-styled component with the right props and TypeScript types. It's a particularly good workflow for error pages because you can iterate on copy and layout fast without touching Figma.

What does a genuinely great error page look like in 2026? It's on-brand, it loads fast, it has exactly the right amount of personality, and it gives the user a clear path forward. That's it. You don't need a custom illustrated character (though they're fun). You don't need a clever pun. You just need to not abandon the user at a dead end.

FAQ

How do I create a 404 page in Next.js App Router?

Create a file at app/not-found.tsx — Next.js automatically serves it with a real HTTP 404 status for unmatched routes. You can also trigger it programmatically with notFound() from next/navigation inside any Server Component.

What's the difference between error.tsx and global-error.tsx in Next.js?

error.tsx catches rendering errors within a route segment and has access to your root layout. global-error.tsx is a last-resort boundary that replaces the entire document, so it must include <html> and <body> tags — use it only as a fallback.

How do I enable maintenance mode in Next.js without redeploying?

Use Next.js Middleware with an environment variable check. Set NEXT_PUBLIC_MAINTENANCE_MODE=true in your deployment config to redirect all traffic to /maintenance, then flip it back when you're done — no code change needed.

Should my 404 page be a Client or Server Component in Next.js?

Server Component by default — and that's fine for most cases. Only switch to a Client Component if you need browser APIs like analytics tracking or need to read the current pathname with usePathname.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark Mode Toggle in React: Sun/Moon, System Preference, No FlashLanguage Switcher in React: Dropdown, Flag Icons, i18n RoutingNext.js vs Remix in 2026: Which One Should You Use?Next.js App Router in 2026: What's Changed and What Still Trips People Up