# React 404 Page: 5 Error Screens for the Next.js App Router

> Build a React 404 page that helps people recover, plus 500, maintenance, offline and no access screens, wired into the Next.js App Router with the right status.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-404-page

## TL;DR

A good React 404 page says what went wrong, offers a search and a few likely destinations, and answers with a real 404 status (or at least noindex) so search engines drop the URL. In the Next.js App Router, not-found.tsx handles 404, error.tsx and global-error.tsx handle 500, forbidden.tsx handles 403, proxy.ts serves maintenance with a 503, and a service worker serves the offline page. Wingo UI's Error Pages block (Pro) renders all five screens from one variant prop.

Most React 404 page templates are a big number, a sad illustration and a "Go home" button. That covers one of the five error screens a real app hits, and the status code is left to whatever the server happens to send. This tutorial builds a React 404 page that helps people get where they were going, then the four screens next to it: a 500 with a retry, a maintenance page with a countdown, an offline page for your PWA, and a no access page that lets people ask for access. Everything is wired into the Next.js App Router with the file conventions of Next.js 16.3. We build Wingo UI and the code uses our [Error Pages](https://wingo-ui.com/components/error-pages) block, so we are not neutral; the wiring works with any component.

## What should a React 404 page do?

It gets the person to a working page and keeps the dead URL out of search. Four things decide whether someone recovers:

- **Say what happened in plain words.** "This page does not exist or was moved", not "Error 404".
- **Offer the next step.** A search field, the three or four pages people want most, and a way back to where they came from.
- **Return the right status.** Google flags a page that answers 200 but shows an error message as a [soft 404](https://developers.google.com/search/docs/crawling-indexing/http-network-errors) in Search Console. The not found UI must come with a 404, or at least a `noindex`.
- **Move focus to the title** after a client-side navigation, so a screen reader user hears that the page changed. Do not do it on the first load, where the browser already starts at the top. The Error Pages block follows that rule through its `autoFocusTitle` prop (on by default).

The same checklist applies to the other four error screens, with a different next step for each.

## Which error screens does a Next.js app need?

Five: not found, server error, maintenance, offline and no access. Each one has its own App Router entry point:

| Screen | App Router entry point | Status | What the person can do |
|---|---|---|---|
| Not found | `app/not-found.tsx`, `notFound()` | 404 (200 + noindex when streamed) | Search, open a suggested page, go back |
| Server error | `app/error.tsx`, `app/global-error.tsx` | 500 | Try again, copy an error id for support |
| Maintenance | `proxy.ts` sends traffic to `/maintenance` | 503 + `Retry-After` | See when it ends, check the status page |
| Offline | A service worker fallback, `useOffline` | none (no network) | Wait for the automatic retry, open saved pages |
| No access | `app/forbidden.tsx`, `forbidden()` | 403 | Request access, switch account |

Facts in this table come from the Next.js 16.3 docs that ship in `node_modules/next/dist/docs`, read on October 16, 2026. `forbidden.tsx` and `useOffline` are still experimental there.

## How do I install a React 404 page template?

Install the Error Pages block with the CLI:

```bash
npx wingo-ui@latest add error-pages
```

That copies `components/blocks/error-pages.tsx` with its data file and the parts it composes (alert, countdown, tracker, dialog, search input and a few more), plus the npm packages they need, among them `motion`, `lucide-react`, `react-hook-form` and `zod`. Error Pages is part of [Wingo UI Pro](https://wingo-ui.com/pricing).

The block's defaults are demo content: an Acme CRM logo, `support@acme.com`, sample admins, a sample stack trace. Pass your own once, in a small client wrapper that also hands it the Next.js `Link`:

```tsx
// components/app-error-page.tsx
"use client"

import Link from "next/link"
import { ErrorPage, type ErrorPageProps } from "@/components/blocks/error-pages"

export function AppErrorPage(props: ErrorPageProps) {
  return (
    <ErrorPage
      linkComponent={Link}
      logo={<span className="font-semibold">Northwind</span>}
      supportHref="mailto:help@northwind.app"
      statusHref=""
      {...props}
    />
  )
}
```

`statusHref=""` hides the status page links until you have one. Server files such as `not-found.tsx` can render this wrapper directly, as long as every prop they pass is plain data, JSX (icons are fine) or a server action. Functions defined in a server file cannot cross into the client wrapper.

## How do I build a Next.js not found page?

Create `app/not-found.tsx`. That file is the Next.js not found page: it renders for every URL that matches no route, and for any segment that calls `notFound()`:

```tsx
// app/not-found.tsx
import { BarChart3, FileText, LayoutDashboard, LifeBuoy } from "lucide-react"
import { AppErrorPage } from "@/components/app-error-page"

const suggestions = [
  { label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard />, description: "Today at a glance" },
  { label: "Invoices", href: "/invoices", icon: <FileText />, description: "Sent, paid and overdue" },
  { label: "Reports", href: "/reports", icon: <BarChart3 />, description: "Revenue by month" },
  { label: "Help center", href: "/help", icon: <LifeBuoy />, description: "Guides and answers" },
]

export default function NotFound() {
  return <AppErrorPage variant="404" suggestions={suggestions} />
}
```

The search field navigates to `/search?q=` on Enter by default; pass `onSearch` from a client component to route it somewhere else, or `showSearch={false}` if you have no search. "Go back" uses `history.back()` when there is history and falls back to the home link when the tab has no history, for example when the link was opened in a new tab.

> Live demo (Error Pages): Press / to focus the search, type a query and press Enter, or hover the suggestion cards: each one is a plain link. Try it at [Error Pages](https://wingo-ui.com/components/error-pages) and install it with `npx wingo-ui@latest add error-pages`.

For a missing record, call `notFound()` in the page that loads it:

```tsx
// app/invoices/[id]/page.tsx
import { notFound } from "next/navigation"
import { getInvoice } from "@/lib/invoices"

export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const invoice = await getInvoice(id)
  if (!invoice) notFound()
  return <InvoiceView invoice={invoice} />
}
```

Two status details the docs spell out. `notFound()` returns a 404 when the response is not streamed yet; if a `loading.tsx` boundary already flushed the shell, the status stays 200 and Next.js injects `<meta name="robots" content="noindex">` instead. If you need the real 404 for analytics, check that the record exists before anything streams. And when an app has several root layouts, the experimental `global-not-found.tsx` (behind `experimental.globalNotFound`) gives you one 404 that renders without any layout.

### When is a full 404 page too much?

When the URL is right and the data is just empty. A search with no matches, a filtered table with zero rows or a new account with no invoices should answer 200 with an empty state inside the page, never a 404. The free [Empty State](https://wingo-ui.com/components/empty-state) covers that case with its `no-results`, `empty`, `no-permission` and `offline` presets, and `live` announces it to screen readers when the results change:

```tsx
// app/invoices/invoice-list-empty.tsx
"use client"

import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"

export function InvoiceListEmpty({ query, clearFilters }: { query: string; clearFilters: () => void }) {
  return (
    <EmptyState
      preset="no-results"
      live
      title={`No invoices match "${query}"`}
      description="Try another client name or clear the filters."
      actions={<Button variant="outline" onClick={clearFilters}>Clear filters</Button>}
    />
  )
}
```

## How do I show a 500 page with a retry?

Add `app/error.tsx` and wire its `retry` prop to the Try again button. The file wraps each segment in a React error boundary, so it must be a client component. Since Next.js 16.3 the `retry` prop is stable. It re-fetches and re-renders the segment, unlike the older `reset()`, which re-renders without re-fetching, so a segment whose data request failed shows the same failure again:

```tsx
// app/error.tsx
"use client"

import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { AppErrorPage } from "@/components/app-error-page"

export default function Error({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
  const pathname = usePathname()

  useEffect(() => {
    console.error(error)
  }, [error])

  return (
    <AppErrorPage
      variant="500"
      errorId={error.digest ?? ""}
      error={error}
      errorPath={pathname}
      showDetails={process.env.NODE_ENV === "development"}
      onRetry={retry}
    />
  )
}
```

`error.digest` is the hash Next.js also writes to the server log, so it makes a good id for people to quote to support: the block copies it with one click. Errors thrown in the browser have no digest, and `errorId=""` hides the id row. In production, errors thrown by server components reach the client with a generic message, and the stack trace is not something visitors need anyway, which is why the technical details only show in development here.

> Live demo (Error Pages): Press Try again: the first attempt fails and shows when, the second succeeds. Open Technical details to see the path and the stack trace. Try it at [Error Pages](https://wingo-ui.com/components/error-pages) and install it with `npx wingo-ui@latest add error-pages`.

`error.tsx` does not catch errors in the root layout. For those, add `app/global-error.tsx`, which replaces the whole document, so it renders its own `<html>` and `<body>` and imports your styles:

```tsx
// app/global-error.tsx
"use client"

import "./globals.css"
import { AppErrorPage } from "@/components/app-error-page"

export default function GlobalError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
  return (
    <html lang="en">
      <body className="bg-background text-foreground">
        <AppErrorPage variant="500" errorId={error.digest ?? ""} showDetails={false} onRetry={retry} />
      </body>
    </html>
  )
}
```

It also sits outside any theme provider, so a class-based dark mode does not reach it: this page renders with your light tokens unless you set the class yourself.

Inside an app shell, pass `layout="inline"` to any variant, for example from a nested `app/(app)/error.tsx`. The page drops its own logo and footer and fills the shell's main area, so a failing report keeps the sidebar of your [Admin Shell](https://wingo-ui.com/components/admin-shell) around it.

## How do I build a maintenance page in React?

Serve it with a 503 status and tell people when it ends. A typical React maintenance page is a static "We will be back soon" that answers 200, which gets both wrong. Google's documentation on [HTTP status codes](https://developers.google.com/search/docs/crawling-indexing/http-network-errors) (updated February 4, 2026) says 5xx responses make its crawlers slow down, that content from those URLs is ignored, and that indexed URLs are kept at first and dropped only if the errors continue. A 200 maintenance page gets none of that protection.

The page itself is an ordinary route:

```tsx
// app/maintenance/page.tsx
import { connection } from "next/server"
import { AppErrorPage } from "@/components/app-error-page"

export default async function Maintenance() {
  // read the env at request time, not at build time
  await connection()

  return (
    <AppErrorPage
      variant="maintenance"
      // an ISO date such as 2026-10-18T04:00:00Z
      until={process.env.MAINTENANCE_UNTIL}
      showStatus={false}
      showSubscribe={false}
    />
  )
}
```

`until` drives a live [Countdown](https://wingo-ui.com/components/countdown); when it reaches zero the page says maintenance is over and the main button becomes Reload. Two pitfalls here. Without `connection()` the page is prerendered and keeps the value `MAINTENANCE_UNTIL` had at build time. And if the variable is missing, `until` falls back to the block's sample date, which is in the past, so the page opens on "Maintenance is over". `showStatus` and `showSubscribe` are off because their defaults are sample data: turn them on once you pass your own `statusHistory` (a 30 day [Tracker](https://wingo-ui.com/components/tracker)) and an `onSubscribe` server action. For a full public uptime page, the [Status Page template](https://wingo-ui.com/templates/status-page) is the separate screen to link from `statusHref`.

To send every request there, use `proxy.ts` (the file that replaced `middleware.ts` in Next.js 16). Do not rely on `NextResponse.rewrite()` for the status: in the 16.3 router a rewrite passes the proxy's headers on, but the destination page renders the response, and a normal page answers 200. To get a real 503, fetch the rendered page and return it with your own status:

```ts
// proxy.ts
import type { NextRequest } from "next/server"

export async function proxy(request: NextRequest) {
  if (process.env.MAINTENANCE !== "on") return

  const page = await fetch(new URL("/maintenance", request.url))
  return new Response(page.body, {
    status: 503,
    headers: { "Content-Type": "text/html; charset=utf-8", "Retry-After": "3600" },
  })
}

export const config = {
  // the maintenance page itself, its assets and robots.txt stay reachable
  matcher: ["/((?!maintenance|_next/static|_next/image|favicon.ico|robots.txt).*)"],
}
```

Keep `/maintenance` out of the matcher or the fetch calls the proxy again. `Retry-After` takes seconds or an HTTP date; set it to the end of the window. Client-side navigations get the same HTML with a 503, so the router falls back to a full page load, which is what you want during maintenance.

## How do I add an offline page to a PWA?

Cache an `/offline` route in a service worker and serve it when a page load fails. That covers full loads; requests inside an app that is already open need a different fix.

**Inside the app**, a soft navigation, prefetch or Server Action fails because the network dropped. Next.js 16.3 has an experimental answer: with `experimental.useOffline` on, those requests are retried when the connection returns, and the `useOffline()` hook from `next/offline` returns `true` so a component can show a banner. Without the flag the hook always returns `false`. No error page needed.

**A full page load with no network.** The browser has no HTML to show, so only a service worker can help. Cache an offline route when it installs and serve it when a navigation fails:

```js
// public/sw.js
const CACHE = "offline-v1"

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.add("/offline")))
  self.skipWaiting()
})

self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()))

self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return
  event.respondWith(fetch(event.request).catch(() => caches.match("/offline")))
})
```

Register it once from a client component in the root layout:

```tsx
// app/register-sw.tsx
"use client"

import { useEffect } from "react"

export function RegisterServiceWorker() {
  useEffect(() => {
    if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js")
  }, [])
  return null
}
```

The offline route checks the connection on its own schedule. The `HEAD` request is not a navigation, so the service worker lets it through to the network:

```tsx
// app/offline/offline-view.tsx
"use client"

import { AppErrorPage } from "@/components/app-error-page"

async function ping() {
  const res = await fetch(window.location.href, { method: "HEAD", cache: "no-store" })
  if (!res.ok) throw new Error("Still offline")
}

export function OfflineView() {
  return <AppErrorPage variant="offline" retryInterval={10} onRetry={ping} offlinePages={[]} />
}
```

Render `<OfflineView />` from `app/offline/page.tsx`. This is the PWA offline page people see after a reload with no signal, still at the URL they asked for. It retries every 10 seconds with a visible progress bar, retries at once when the browser fires `online`, and reloads the original URL when a check passes. `offlinePages={[]}` hides the "saved on this device" list until you cache pages for real.

One catch with the minimal worker above: it caches only the HTML of `/offline`. The CSS and JavaScript chunks under `/_next/static` are not in the cache, so with no network the page shows unstyled and the retry loop never starts. Precache those files too. [Serwist](https://github.com/serwist/serwist), the option the Next.js PWA guide points to, builds that precache list from your build output. If you are turning the app into a PWA, our post on the [React PWA install prompt](https://wingo-ui.com/blog/react-pwa-install-prompt) covers the install side.

## How do I show a no access page?

Return a 403 that says who can grant access. For a signed-in person without the right role, a 404 hides that the page exists and a redirect to sign-in loops. Turn on `experimental.authInterrupts` in `next.config.ts`, call `forbidden()` from `next/navigation` where you check the role (Next.js adds a `noindex` tag to that response), and add `app/forbidden.tsx`. The status works like `notFound()`: you get a real 403 only when the check runs before the response starts streaming.

```tsx
// app/forbidden.tsx
import { AppErrorPage } from "@/components/app-error-page"
import { getSession, getWorkspaceAdmins } from "@/lib/auth"
import { requestAccess, switchAccount } from "./access-actions"

export default async function Forbidden() {
  const session = await getSession()
  const admins = await getWorkspaceAdmins()

  return (
    <AppErrorPage
      variant="forbidden"
      resource="Financial reports"
      admins={admins}
      account={session ? { name: session.user.name, email: session.user.email } : null}
      onRequestAccess={requestAccess}
      onSwitchAccount={switchAccount}
    />
  )
}
```

`getSession`, `getWorkspaceAdmins` and the two server actions are yours. `requestAccess` receives `{ adminId, reason }`; a rejected promise shows its message in the dialog, which opens as a bottom sheet on phones. One production pitfall: Next.js replaces the message of an error thrown inside a server action with a generic one, so "You already have a pending request" would not reach the dialog. Have the action return `{ error }` instead, and pass `onRequestAccess` from a small client component that calls it and throws `new Error(result.error)`. After a successful request the button turns into a disabled "Request sent" with the time.

> Live demo (Error Pages): Press Request access, then Send request without filling anything to see both errors. Pick an admin, write a reason and send it again. Try it at [Error Pages](https://wingo-ui.com/components/error-pages) and install it with `npx wingo-ui@latest add error-pages`.

## What does the finished setup look like?

Six route files, a proxy, a service worker and one wrapper around one block:

```text
app/
  not-found.tsx        404 for unknown URLs and notFound()
  error.tsx            500 per segment, retry()
  global-error.tsx     500 when the root layout fails
  forbidden.tsx        403 from forbidden()
  maintenance/page.tsx served with a 503 by proxy.ts
  offline/page.tsx     cached by public/sw.js
  register-sw.tsx      registers the service worker
components/
  app-error-page.tsx   your logo, support address and Link
proxy.ts               503 while MAINTENANCE=on
public/sw.js           offline fallback for page loads
```

To put the inline variants inside a real app, our [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) builds the shell they fit into, and more full screens live in the [Build it category](https://wingo-ui.com/blog/category/build).

Start with the free [Empty State](https://wingo-ui.com/components/empty-state) for in-page dead ends, and add the [Error Pages](https://wingo-ui.com/components/error-pages) block when you want all five full screens: `npx wingo-ui@latest add error-pages`.

## Components in this post

- [Error Pages](https://wingo-ui.com/components/error-pages) (Pro): React error pages in one block: 404 not found, 500 server error, maintenance, offline and no permission, full page or inside an app shell. Install: `npx wingo-ui@latest add error-pages`
- [Empty State](https://wingo-ui.com/components/empty-state) (Free): A React empty state for lists, tables, searches and pages with nothing to show yet: an icon tile, a title, a description and actions. Install: `npx wingo-ui@latest add empty-state`

## FAQ

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

Add app/not-found.tsx. It renders for every unmatched URL and whenever a server component calls notFound() from next/navigation, and Next.js adds a noindex meta tag to it automatically.

### Does the Next.js not found page return a 404 status code?

It returns 404 when the response is not streamed. If a loading.tsx boundary already started streaming before notFound() ran, the status stays 200, but Next.js injects a noindex meta tag so the URL is still kept out of the index.

### How do I add a 404 page in React Router?

Add a catch-all route with path="*" that renders your not found component. In a single page app the server still answers 200 for every path, so configure the host to answer 404 for paths outside your route list if search traffic matters.

### What status code should a maintenance page return?

503 Service Unavailable, with a Retry-After header that says when to come back. Google documents that 5xx responses make its crawlers slow down, that their content is discarded and that indexed URLs are kept at first; a 200 maintenance page gets none of that protection.

### How do I show an offline page in a PWA?

Cache an /offline route when the service worker installs and serve it when a navigation request fails. For an app that is already open, the experimental useOffline option in Next.js 16.3 retries failed navigations, prefetches and Server Actions when the connection returns, and the useOffline hook from next/offline reports the state.

---

Source: https://wingo-ui.com/blog/react-404-page
