Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. React 404 Page: 5 Error Screens for the Next.js App Router
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. React 404 Page: 5 Error Screens for the Next.js App Router
Build it: screens and apps
Build it: screens and apps

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

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 11 min read

On this page

0%
  1. What should a React 404 page do?
  2. Which error screens does a Next.js app need?
  3. How do I install a React 404 page template?
  4. How do I build a Next.js not found page?
    1. When is a full 404 page too much?
  5. How do I show a 500 page with a retry?
  6. How do I build a maintenance page in React?
  7. How do I add an offline page to a PWA?
  8. How do I show a no access page?
  9. What does the finished setup look like?
  10. FAQ
    1. How do I create a 404 page in Next.js App Router?
    2. Does the Next.js not found page return a 404 status code?
    3. How do I add a 404 page in React Router?
    4. What status code should a maintenance page return?
    5. How do I show an offline page in a PWA?

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.

Published Oct 16, 2026

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 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 (opens in a new tab) 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:

ScreenApp Router entry pointStatusWhat the person can do
Not foundapp/not-found.tsx, notFound()404 (200 + noindex when streamed)Search, open a suggested page, go back
Server errorapp/error.tsx, app/global-error.tsx500Try again, copy an error id for support
Maintenanceproxy.ts sends traffic to /maintenance503 + Retry-AfterSee when it ends, check the status page
OfflineA service worker fallback, useOfflinenone (no network)Wait for the automatic retry, open saved pages
No accessapp/forbidden.tsx, forbidden()403Request 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.

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.

Press / to focus the search, type a query and press Enter, or hover the suggestion cards: each one is a plain link
$ npx wingo-ui@latest add error-pages
ProError Pages docs

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 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.

Press Try again: the first attempt fails and shows when, the second succeeds. Open Technical details to see the path and the stack trace
$ npx wingo-ui@latest add error-pages
ProError Pages docs

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 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 (opens in a new tab) (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; 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) and an onSubscribe server action. For a full public uptime page, the Status Page template 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 (opens in a new tab), 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 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.

Press Request access, then Send request without filling anything to see both errors. Pick an admin, write a reason and send it again
$ npx wingo-ui@latest add error-pages
ProError Pages docs

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 builds the shell they fit into, and more full screens live in the Build it category.

Start with the free Empty State for in-page dead ends, and add the Error Pages block when you want all five full screens: npx wingo-ui@latest add error-pages.

Components in this post

  • Error Pages

    React error pages in one block: 404 not found, 500 server error, maintenance, offline and no permission, full page or inside an app shell.

    Pro
  • Empty State

    A React empty state for lists, tables, searches and pages with nothing to show yet: an icon tile, a title, a description and actions.

    Free

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.

  • Error Pages
  • Next.js
  • App Router
  • PWA
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Empty State Component: First Use, No Results, Errors

Build a React empty state component for first use, no results and errors, with copy that says what happened and one action that moves people forward.

SRSerban Rusu·Oct 19, 2026·8 min read
Component guides

React Skeleton Loader: Real Shapes, No Layout Shift

Build a React skeleton loader shaped like the real content, pick a spinner when a skeleton is wrong, and swap in the data without any layout shift.

SRSerban Rusu·Oct 19, 2026·9 min read
Build it: screens and apps

Nextjs Blog Template: Markdown, Live Demos and JSON-LD

How we built a nextjs blog template on Markdown files: zod frontmatter, live React demos, a sticky table of contents, JSON-LD, feeds and a strict validator.

SRSerban Rusu·Oct 16, 2026·10 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI