Next.js Error Pages: not-found, error.js, Global Error and Middleware
Next.js error handling is split across several files: not-found for missing pages, error for crashes inside a route, global-error for failures in the root layout, and middleware, which can fail every request at once with a 500 and the code MIDDLEWARE_INVOCATION_FAILED on Vercel. When these are missing or set up wrongly, visitors see blank screens, and search engines see missing pages that answer with a 200 status, which Google reports as soft 404s. This guide shows you which file catches which error, how to make each one return the right status code, how to read the logs that reveal a middleware crash, and how to test the result with curl and Search Console so it stays fixed.
How Next.js error handling works
Next.js gives you a separate file for each kind of failure, and each one has a different job. If you mix them up, visitors see a blank screen, or Google sees a page that says it is missing but answers with a 200 status code. Getting the files right is mostly about knowing which failure each one catches.
In the App Router (the app folder), these are the files that matter:
| File | What it catches | Status code it should produce |
|---|---|---|
not-found.js | URLs that match no route, and any call to notFound() | 404 |
error.js | Errors thrown while rendering a route segment and its children | 500 for a server render error |
global-error.js | Errors in the root layout or root template, where error.js cannot help | 500 |
middleware.js or proxy.js | Nothing: it runs before routing, and a crash here fails the whole request | 500 (MIDDLEWARE_INVOCATION_FAILED on Vercel) |
The Pages Router (the pages folder) uses older equivalents: pages/404.js for missing pages, pages/500.js for server errors, and pages/_error.js as a catch-all that receives the status code. Both routers can live in the same project, and the App Router files take over for routes inside app.
Two rules explain most problems. First, error.js is a React error boundary, so it wraps the segment's page and children, but not the layout.js in the same folder. An error in a layout bubbles up to the parent segment's error.js, and an error in the root layout goes to global-error.js. Second, the status code is decided by the server when the response starts. Once Next.js has begun streaming HTML to the browser, it cannot change the status, so a notFound() call that happens late in a streamed page is sent with a 200 and a noindex robots tag instead of a 404.
Common causes of broken error pages in Next.js
Most Next.js error page problems come from a short list of mistakes. Check your project against this table before you change any code.
| Symptom | Likely cause | Where to look |
|---|---|---|
| Missing product shows an empty page with a 200 | Data fetch returns null and the page renders anyway instead of calling notFound() | The dynamic route, for example app/products/[slug]/page.js |
| Default black and white 404 instead of your design | No app/not-found.js, or it sits in a segment that unmatched URLs never reach | Root of the app folder |
| White screen with Application error: a client-side exception | An error in the browser with no error.js above it | Browser console, then the nearest error.js |
| Root layout crash shows the default error | No global-error.js, or it does not render its own html and body tags | app/global-error.js |
| 500 INTERNAL_SERVER_ERROR, code MIDDLEWARE_INVOCATION_FAILED | Middleware throws, uses an unsupported API, or reads a missing environment variable | middleware.js or proxy.js and Vercel logs |
| Every page 404s after deploy | basePath, trailingSlash or a rewrite in next.config.js no longer matches the URLs | next.config.js |
| Error page itself returns 500 | The error component imports something that also fails, such as the same broken data call | error.js imports |
The first row is the one that hurts search the most. A catch-all or dynamic route that shows a friendly message for an unknown slug, without calling notFound(), turns every mistyped or deleted URL into an indexable thin page. Our 404 errors guide explains why a real 404 is the right answer for content that has gone.
How to diagnose the problem
Start by finding out what status code each failing URL really returns. The browser page can look correct while the status is wrong, so test with curl rather than by eye:
# Status line and headers only
curl -sI https://www.example.com/products/does-not-exist
# Follow redirects and print just the final status
curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" -L https://www.example.com/old-page
# Look for the noindex tag Next.js adds when the status could not be changed
curl -s https://www.example.com/products/does-not-exist | grep -i 'name="robots"'A missing page should print 404. If it prints 200 and the grep finds a noindex robots tag, the page is streaming before notFound() runs. If it prints 200 with no robots tag, the route is not calling notFound() at all.
Finding a MIDDLEWARE_INVOCATION_FAILED error
On Vercel, this 500 means the middleware function threw or could not run. The error page shows only the code and an ID, so you need the logs. Open the project in the Vercel dashboard, go to Logs, and filter by the failing path. The log entry for the middleware invocation holds the real exception, such as a TypeError or a missing module. From the command line you can stream logs for a deployment:
npx vercel logs https://my-app-abc123.vercel.appReproduce locally with next build followed by next start, not just next dev. The development server is more forgiving, and some middleware errors only appear in a production build.
Checking for client-side errors
If the page renders and then turns white, open Chrome DevTools, go to the Console, and reload. The first red error is usually the cause. A missing error.js above that component is why the whole page disappears instead of showing a recovery message. For broader checks across a site, the Next.js site audit guide covers crawling a build for status codes.
The key files and code
A not-found page that returns a real 404
Put not-found.js at the root of app so it handles every unmatched URL. Keep it a Server Component and avoid slow data calls in it.
// app/not-found.js
import Link from 'next/link';
export const metadata = { title: 'Page not found | Example Bakery' };
export default function NotFound() {
return (
<main>
<h1>We could not find that page</h1>
<p>The page may have moved or been removed.</p>
<Link href="/">Back to the home page</Link>
</main>
);
}Then make every dynamic route call notFound() as soon as it knows the item does not exist, before anything streams:
// app/products/[slug]/page.js
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return <h1>{product.name}</h1>;
}Call the same check in generateMetadata if the route has one. Metadata is resolved before the page streams, so a notFound() there reliably produces a 404. Avoid wrapping the whole page in a loading.js or a Suspense boundary that starts streaming before the lookup finishes.
An error boundary for a segment
// app/products/error.js
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong loading this page</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}The 'use client' line is required, because error boundaries run in the browser. In production, server errors reach the component with a generic message and a digest value so that secrets are not leaked; search your server logs for that digest to find the real stack trace.
A global error page
// app/global-error.js
'use client';
export default function GlobalError({ error, reset }) {
return (
<html lang="en">
<body>
<h1>Sorry, the site had a problem</h1>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}Because it replaces the root layout when it is active, global-error.js must render its own <html> and <body> tags. It does not get your layout's fonts or stylesheets, so keep it simple.
Middleware that cannot crash the request
// middleware.js (in Next.js 16: proxy.js, exporting a function named proxy)
import { NextResponse } from 'next/server';
export function middleware(request) {
try {
const country = request.headers.get('x-vercel-ip-country') || 'GB';
const res = NextResponse.next();
res.headers.set('x-country', country);
return res;
} catch (err) {
console.error('middleware failed', err);
return NextResponse.next();
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
};The matcher stops middleware running on static files, which cuts the number of invocations and the chance of a failure on assets. The try and catch turns an unexpected error into a logged warning and a normal response rather than a 500 for every visitor.
Fixing MIDDLEWARE_INVOCATION_FAILED on Vercel
The full message usually reads 500: INTERNAL_SERVER_ERROR with Code: MIDDLEWARE_INVOCATION_FAILED and an ID. It means Vercel started your middleware and it ended with an unhandled error. Every request the matcher covers fails, which is why it often takes down a whole site at once. These are the usual reasons, roughly in order of how often they come up:
- An unhandled exception. Reading a property of
undefined, parsing a cookie that is not valid JSON, or callingnew URL()on a malformed value. Wrap risky parsing intryandcatch. - A missing environment variable. A variable set locally in
.env.localbut not added in the Vercel project settings for Production. Check Settings, then Environment Variables, and redeploy after adding it, because values are read at build and deploy time. - An API the runtime does not support. Middleware has traditionally run on the Edge runtime, which has no
fs, no native modules and only part of the Node.js API. Importing a database driver or an authentication library built for Node is a common trigger. Newer Next.js versions can run middleware on the Node.js runtime, and the Next.js 16proxy.jsfile uses it by default, but check what your version does. - A failing or slow fetch. Calling an external API in middleware on every request. If it rejects without a catch, the invocation fails; if it hangs, you get a timeout instead (MIDDLEWARE_INVOCATION_TIMEOUT, a 504).
- A redirect loop or bad rewrite. Middleware that redirects
/loginto/login, or rewrites to a URL that runs the same middleware again.
Once the cause is fixed, redeploy and test a few paths with curl -sI. Our 500 errors guide covers the general server-side checks, and Server error (5xx) in Search Console explains what Google does if the outage lasted long enough to be crawled.
A worked example
Example Bakery runs a Next.js 15 shop on Vercel. Two things go wrong after a redesign: deleted cake pages still show in Google, and on Monday morning the whole site shows 500: INTERNAL_SERVER_ERROR, Code: MIDDLEWARE_INVOCATION_FAILED.
For the cakes, curl -sI https://www.example.com/cakes/lemon-drizzle returns HTTP/2 200. The page template shows Sorry, this cake is no longer available when the database returns nothing, but never calls notFound(). Google has kept 140 of these URLs as thin pages. The fix is two lines: import notFound and call it when the lookup is empty, in both the page and generateMetadata. After deploying, curl returns HTTP/2 404 and the URL Inspection live test in Search Console reports the 404.
For the outage, the Vercel logs for the middleware show TypeError: Cannot read properties of undefined (reading 'split'). The middleware reads a BASIC_AUTH variable to protect a staging path, and that variable was added only to the Preview environment. On Production it is undefined, so .split(':') throws on every request. The developer adds the variable to Production, changes the code to skip the check when the value is missing, narrows the matcher to /staging/:path*, and redeploys. The site recovers within a minute, and the next deploy can no longer take it down for the same reason.
The fix, step by step
Step 1
Test the real status codes
Run curl -sI against a URL you know is missing, a URL whose item was deleted, and a page that is failing. Write down the status each one returns and whether the HTML contains a noindex robots tag. This tells you whether the problem is a missing notFound() call, a streamed response, a crashing component or the middleware. Repeat the test after each change in the following steps, so you can see exactly which change fixed which symptom.
Step 2
Add a root not-found page
Create app/not-found.js at the root of the app folder with a clear message, a link back to the home page and a short metadata title. Keep it as a Server Component with no slow data calls. Build and start the app with next build and next start, then request a random path such as /zz-test-404 with curl. You should see your design in the browser and a 404 status in the response headers.
Step 3
Call notFound in every dynamic route
Open each dynamic route, such as app/products/[slug]/page.js, and call notFound() straight after the data lookup when it returns nothing. Add the same check to generateMetadata where the route has one, because metadata resolves before streaming starts. For the Pages Router, return { notFound: true } from getStaticProps or getServerSideProps. Confirm with curl that deleted items now return 404 instead of 200, and check a live item still returns 200.
Step 4
Add error boundaries
Create an error.js file with 'use client' at the top in each major segment, and one at the root of app. Give it a message and a button that calls reset(). Remember that it does not catch errors in the layout of the same folder, so place it one level up if a layout can fail. Throw a test error in a page during development and check that the boundary shows instead of a white screen.
Step 5
Add global-error.js
Create app/global-error.js as a Client Component that renders its own html and body tags, a short apology and a retry button. This is the only file that can catch an error in the root layout. Keep it free of imports from your layout, fonts or data layer, so it cannot fail for the same reason. Test it by temporarily throwing inside the root layout in a production build, then remove the test error.
Step 6
Read the middleware logs
If you see MIDDLEWARE_INVOCATION_FAILED, open the Vercel dashboard, go to Logs and filter by the failing path to find the real exception. Note the error message and line. Common findings are a property read on undefined, a missing environment variable or a Node.js-only package imported into Edge middleware. Reproduce it locally with a production build so you can test the fix before you redeploy to Production.
Step 7
Make the middleware fail safely
Fix the root cause first: add the missing variable to the Production environment, replace unsupported imports, or move heavy work into a route handler. Then wrap risky parsing in try and catch and return NextResponse.next() on failure where that is safe. Narrow the matcher so middleware skips static files and paths that do not need it. Redeploy and test several paths with curl to confirm normal 200 responses.
Step 8
Verify in Search Console
Use URL Inspection on a deleted URL and a normal page and run a live test on each. The deleted one should report a 404 and the normal one should be available to Google. In the Pages report, open Soft 404 and Server error (5xx) and use Validate fix once your changes are live. Validation can take several days to a few weeks, so keep testing with curl in the meantime.
Edge cases worth knowing
- Streamed 404s. If you need a true 404 status from a page that uses Suspense, do the existence check before any Suspense boundary, or in
generateMetadata. Otherwise you get a 200 with anoindextag, which keeps the page out of the index but is still a soft 404. - Nested not-found files. A
not-found.jsinside a segment only shows fornotFound()calls in that segment. Unmatched URLs always use the root one. - Static export. With
output: 'export'there is no server, so the host decides the status. Configure it to serve404.htmlwith a 404 status, for example with an nginxerror_pagerule. - Redirect instead of 404. Redirecting every missing URL to the home page is treated by Google as a soft 404. Redirect only when there is a genuine replacement, as covered in how to set up a 301 redirect.
- Errors in event handlers. Error boundaries do not catch errors in click handlers or timers. Handle those with
tryandcatchin the handler. - Self-hosting. Behind nginx or another proxy, check that the proxy passes Next.js status codes through and does not replace 404 and 500 responses with its own pages.
Keeping your error pages correct
Error handling breaks quietly, usually during a refactor, so add checks that run without you remembering. A short script in your deploy pipeline that requests a known missing URL and fails the build if it does not get a 404 catches most regressions:
status=$(curl -s -o /dev/null -w "%{http_code}" https://preview.example.com/this-page-does-not-exist)
[ "$status" = "404" ] || { echo "Expected 404, got $status"; exit 1; }- Keep every Production environment variable that middleware needs listed in your README, and check them before each release.
- Send middleware and
error.jslogs to a monitoring service so you hear about failures before your visitors do. - Re-crawl the site after major changes and look for 200 responses on pages that should be gone, and 5xx responses anywhere. The HTTP error audit guide explains how to read the results.
- Watch the Pages report in Search Console for Soft 404 and Server error (5xx) entries after each launch.
For the wider picture of how Next.js sites get crawled and indexed, see Next.js SEO fixes.
1. What does 500 INTERNAL_SERVER_ERROR code MIDDLEWARE_INVOCATION_FAILED mean?
It means your Next.js middleware started on Vercel and then failed with an unhandled error, so Vercel returned a 500 for the request. Because middleware runs before every matching route, one bug can take the whole site down. The real cause is in the Vercel logs for the failing path, and it is usually an exception such as reading a property of undefined, an environment variable that exists in Preview but not Production, or a package that the Edge runtime cannot run.
2. How do I show a custom 404 page in Next.js?
Create a file called not-found.js at the root of the app folder and export a component from it. Next.js shows it for any URL that matches no route and for any call to the notFound function. In the Pages Router the equivalent is pages/404.js. Keep the page simple and fast, include a link to the home page or search, and test with curl that the response has a 404 status rather than a 200, because the design alone does not prove the status is correct.
3. What is global-error.js in Next.js?
It is the error boundary that wraps your entire App Router application, including the root layout, so it is the last line of defence when something fails at the top level. A normal error.js cannot catch errors in the layout of its own folder, which is why the root layout needs this separate file. When it is active it replaces the root layout, so it must render its own html and body tags, and it must be a Client Component.
4. Why does my Next.js 404 page return a 200 status?
Usually because the route never calls notFound, or calls it after the page has started streaming. A page that renders a not found message from its own template keeps the normal 200 status. If the call happens inside a Suspense boundary after HTML has been sent, Next.js cannot change the status and instead adds a noindex robots tag. Move the existence check to the top of the page or into generateMetadata so it runs before streaming begins.
5. Does error.js catch every error in Next.js?
No, error.js only catches errors thrown while rendering its route segment and the segments below it. It does not catch errors in the layout.js or template.js of the same folder, errors in event handlers such as click handlers, errors in middleware, or errors in the root layout. Layout errors go to the parent segment's error file, root layout errors go to global-error.js, and event handler errors need their own try and catch blocks in your code.
6. Are Next.js error pages bad for SEO?
Error pages are not bad for SEO when they return the correct status code. A 404 for content that has gone tells Google to drop the URL, and a short 500 tells it to try again later. The damage comes from pages that look like errors but return 200, which Google reports as soft 404s, and from long runs of 5xx errors, which can slow crawling and eventually remove pages from results if they continue for days.
7. How do I fix middleware errors on Vercel without breaking my site?
Find the real exception in the Vercel logs, fix that cause, and then make the middleware fail safely. Add any missing environment variables to the Production environment and redeploy, replace packages that need Node.js APIs, and wrap parsing of cookies, headers and URLs in try and catch. Narrow the matcher so middleware only runs on paths that need it. Avoid deleting middleware outright if it protects private pages or sets security headers, and test a handful of paths with curl after every redeploy.
Our HTTP error audit requests your URLs the way a crawler does and flags soft 404s, 5xx responses and pages whose status does not match what they show.
Open HTTP Error Fixes โ