Docs

Return 404 for missing pages

Return 404 for missing pages

When someone opens an address that does not exist, the server should answer with status 404 Not Found (or 410 Gone for a page that was removed on purpose). Many sites show an error page but send it with 200 OK, or send every unknown address to the home page. To a visitor it looks the same. To Google it is a soft 404: a page that claims to exist, with no content of its own.

The cost is real. Google spends its crawl time on empty addresses, old links and typos stay "valid" forever, and the reports in Search Console fill with soft 404 warnings that hide real problems. Broken-link checkers, including ours, cannot tell a missing page from a working one. The site checklist requests an address that cannot exist on your site and warns when the answer is not 404 or 410.

What causes it

What happens Typical cause
Error page, status 200 custom error page served as a normal page; SPA or catch-all route that renders "not found" in the browser; nginx try_files … /index.html
Redirect to the home page WordPress plugin that sends all 404s home; redirect rule in the server, CDN or builder
Redirect to an error page, then 200 Apache ErrorDocument with a full URL; nginx error_page 404 with a URL; IIS errors in redirect mode

We follow redirects the way a browser does, so a missing page that ends on the home page with 200 counts as a soft 404. A redirect from an old address to its new page is fine; that is what redirects are for. Sending every unknown address to the home page is not.

Check it yourself

Replace example.com with your site. The address below does not exist on any site:

curl -s -o /dev/null -w '%{http_code} %{url_effective}\n' -L https://example.com/this-page-does-not-exist-7f3a
curl -sI https://example.com/this-page-does-not-exist-7f3a | grep -iE '^(HTTP|location):'

The first command follows redirects and prints the final status and address, the second shows the first answer. Fixed means 404 (or 410) in both, with no Location header.

How to fix

Pick what you have access to. The goal is the same everywhere: keep your nice error page, but send it with status 404 and at the address the visitor asked for.

WordPress

WordPress itself returns 404 for a missing address, with the theme's 404.php template. When it does not, something changes that:

  1. A redirect plugin. Plugins that "redirect all 404s to the home page" are the most common cause. Go through Plugins → Installed Plugins and look for one with 404 or redirect in the name or description. In redirect plugins, look for an option that redirects every 404 and turn it off. Keep the redirects for specific old addresses.
  2. The theme. Open the theme's 404.php. If it calls wp_redirect() or wp_safe_redirect(), the theme sends visitors away. Override 404.php in a child theme without the redirect.
  3. Permalinks. If missing pages show the home page with no redirect at all, open Settings → Permalinks and click Save Changes without changing anything. This rewrites the rules in .htaccess.

If you designed the 404 page with a page builder (for example an Elementor theme builder template), the status stays 404; only the look changes.

Then clear the page cache: some caching plugins can store a soft 404 page as a normal page.

nginx

Look at the server { … } block for the site: /etc/nginx/sites-available/example.com on Debian and Ubuntu, /etc/nginx/conf.d/example.com.conf on Rocky, Alma and CentOS.

Static site. The last item in try_files decides what happens to an unknown address. Make it =404 and show your own page with error_page:

location / {
    try_files $uri $uri/ =404;
}

error_page 404 /404.html;
location = /404.html {
    internal;
}

Three variants cause a soft 404:

  • try_files $uri $uri/ /index.html; serves the home page for every address, with 200. This is the usual single-page app setup; see the Node.js and Next.js tab for what to do there.
  • error_page 404 = /index.html; with the = replaces 404 with the status of the page it serves (200). Leave out the =.
  • error_page 404 https://example.com/; with a full URL sends a redirect. Use a local path.

PHP apps (WordPress, Laravel) use try_files $uri $uri/ /index.php?$args;. That is correct: the app decides and returns 404 itself.

Test and apply:

sudo nginx -t && sudo systemctl reload nginx

Apache and LiteSpeed

In .htaccess in the site's root (or in the virtual host on your own server), use a local path for the error page:

ErrorDocument 404 /404.html

With a full URL (ErrorDocument 404 https://example.com/404.html) Apache sends a redirect to that page instead of the 404 status, and the visitor ends on an error page that answers 200.

Two other lines cause soft 404s on static sites and single-page apps:

# Every unknown address gets index.html with status 200:
FallbackResource /index.html
RewriteRule . /index.html [L]

On a static site, remove them. On a single-page app, see the Node.js and Next.js tab.

On your own server, test and apply:

sudo apachectl configtest && sudo systemctl reload apache2

(httpd instead of apache2 on Rocky, Alma and CentOS. .htaccess changes apply immediately.)

Node.js and Next.js

Next.js, App Router. Your app/not-found.tsx is sent with status 404 when no route matches. For dynamic routes ([slug], [...slug]) you have to say so yourself when there is nothing to show; otherwise the page renders empty with 200:

import { notFound } from 'next/navigation';

export default async function Page({ params }) {
    const { slug } = await params;
    const post = await getPost(slug);

    if (!post) {
        notFound();
    }

    return <Article post={post} />;
}

Next.js, Pages Router. Return { notFound: true } from getStaticProps or getServerSideProps; Next.js then serves pages/404.js with 404.

Express. Add a handler after all routes:

app.use((req, res) => {
    res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
});

Single-page apps without a server (React, Vue or Angular built to static files). The server sends index.html for every address and only the browser knows the route does not exist, so the status is always 200. The real fixes are on the server: render or prerender the pages (Next.js, Nuxt, Astro, a prerender step), or give the web server the list of real routes and return 404 for the rest. Until then, at least add <meta name="robots" content="noindex"> to the "not found" view, so Google does not keep those addresses.

Laravel

Laravel returns 404 when no route matches and shows resources/views/errors/404.blade.php if you have one, with the status kept. Soft 404s come from code that catches everything:

// Soft 404: a fallback that returns a page with 200.
Route::fallback(fn () => view('home'));

// Correct: keep the fallback, but answer 404.
Route::fallback(fn () => response()->view('errors.404', [], 404));

In controllers, use findOrFail() or abort(404) when a record does not exist, instead of returning a view with an empty state:

$post = Post::where('slug', $slug)->firstOrFail();

Cloudflare

Cloudflare Pages. If the deployed files have no 404.html at the top level, Pages treats the project as a single-page app and serves index.html with 200 for every unknown address. Add a 404.html to the build output and Pages will send it with status 404.

Workers with static assets. In the Wrangler configuration, not_found_handling = "single-page-application" has the same effect. For a site that is not a single-page app, use "404-page".

Redirect rules. If unknown addresses end on the home page, check Rules in the domain's dashboard for a redirect rule or Page Rule with a wildcard that sends everything to /. Narrow it to the old addresses it was meant for.

IIS and ASP.NET

In web.config, serve the custom page in place of the error, not as a redirect:

<configuration>
  <system.webServer>
    <httpErrors errorMode="Custom" existingResponse="Replace">
      <remove statusCode="404" />
      <error statusCode="404" path="/404.html" responseMode="ExecuteURL" />
    </httpErrors>
  </system.webServer>
</configuration>

responseMode="Redirect" sends a 302 to the error page, which then answers 200. The older ASP.NET setting <customErrors> does the same by default; if you use it, set redirectMode="ResponseRewrite".

Static HTML

Static hosts look for a 404.html file and send it with status 404: Netlify, GitHub Pages, Cloudflare Pages and most others. Put one in the root of the site.

On Netlify, a catch-all rule in _redirects or netlify.toml turns every unknown address into the home page with 200:

/*    /index.html    200

Remove it unless the site is a single-page app. On ordinary shared hosting, use the Apache and LiteSpeed tab.

Hosted platforms

Shopify, Wix, Squarespace and Webflow return a real 404 for missing pages and let you design the 404 page. If the check still fails there, look for a redirect: a Shopify app that redirects 404s, a wildcard rule among Webflow's 301 redirects, or a URL redirect set up in Wix or Squarespace that sends a whole path to the home page.

Confirm the fix

Run the two curl commands from Check it yourself again, then open the target in PulseKeeper and click Check now on the Checklist tab. The item turns OK within a few minutes instead of at the next daily run.