Technical note

Soft 404 detection: separating real empty pages from legitimate inventory gaps

A page can return 200 yet look like an error to Google. Separate missing resources from useful empty states and return the status that matches the page.

By Niko · Published July 30, 2026 · 5 min read

Google's Search Console reports soft 404s when a URL returns a success response but the rendered page behaves like an error or has no main content. Common causes include missing server-side includes, failed data requests, empty internal-search results, and pagination beyond the end of a collection. Google's troubleshooting guide recommends checking both the response and what Google rendered.

The problem is that not all empty-looking pages are errors. A seasonal product category that is temporarily empty can still explain the category, expected availability, and useful alternatives. A no-results search page may be functioning correctly but still have little reason to appear in search. Decide from the URL's purpose and content instead of assuming every empty state deserves the same response.

How Google detects soft 404s

Check the exact URL in URL Inspection and compare its rendered main content with a healthy page from the same template. A word count or phrase match can help triage a large export, but Google does not publish a phrase list or percentage formula. A short page can be legitimate; a long custom error page is still an error.

Classifying soft 404s with Python

A conservative script can label review candidates without pretending to reproduce Google's classifier:

import requests
from bs4 import BeautifulSoup

ERROR_PHRASES = ('not found', 'no results', 'nothing here', 'no matches')

def triage_empty_response(url):
    try:
        response = requests.get(url, timeout=10)
        text = BeautifulSoup(response.text, 'html.parser').get_text(' ', strip=True)
        return {
            'url': url,
            'status': response.status_code,
            'words': len(text.split()),
            'error_language': [p for p in ERROR_PHRASES if p in text.lower()],
        }
    except requests.RequestException as error:
        return {'url': url, 'fetch_error': str(error)}

Use the output alongside the route type and rendered screenshot. It is a review queue, not an indexability verdict.

Match the response to the URL state

URL stateRecommended response
Resource never existed or has no replacement404 with a useful custom error page
Resource was intentionally removed and will not return404 or 410
Resource moved to one clear replacementDirect 301 to that replacement
Valid internal-search query with zero matches200 can be correct; normally keep internal-search results out of the index
Existing category temporarily has no available inventory200 only if the page still has a useful category purpose and accurate alternatives
Pagination beyond the last real page404

In Laravel, keep a no-results search page distinct from a missing route:

public function search(Request $request)
{
    $query = $request->get('q');
    $results = Product::search($query)->paginate(20);

    if ($results->isEmpty()) {
        return response()->view('search.empty', [
            'query' => $query,
            'suggestions' => $this->fallbackSuggestions($query),
        ], 200);
    }

    return view('search.results', compact('results', 'query'));
}

Apply noindex to internal-search results through one template or response-header owner if they should not appear in Search. Do not block those URLs in robots.txt before Google can read the directive.

For an existing category, distinguish "no products exist" from "products exist but none are currently available":

public function show($slug)
{
    $category = Category::where('slug', $slug)->firstOrFail();
    $products = $category->products()->where('in_stock', true)->paginate(24);

    if ($products->isEmpty()) {
        // Check if this is a temporary or permanent state
        if ($category->products()->count() === 0) {
            // Category has no products at all—permanent
            abort(404);
        }

        // Category has products but all out of stock—temporary
        return response()->view('categories.empty', [
            'category' => $category,
            'alternatives' => Category::where('parent_id', $category->parent_id)
                ->where('id', '!=', $category->id)
                ->take(3)
                ->get(),
        ], 200);
    }

    return view('categories.show', compact('category', 'products'));
}

Verify pagination at the boundary

Check zero, negative, malformed, and over-limit page numbers. A valid framework-level not-found response must produce a real HTTP 404, not a 200 app shell that changes into an error after JavaScript runs:

export async function getStaticProps({ params }: { params: { page: string } }) {
  const pageNum = parseInt(params.page, 10);
  const { posts, totalPages } = await getPostsPage(pageNum);

  if (pageNum > totalPages) {
    return {
      notFound: true, // Returns 404
    };
  }

  return {
    props: { posts, page: pageNum, totalPages },
  };
}

Track affected templates and counts against your own baseline rather than inventing a sitewide threshold. The durable fix is at the route boundary: return success for a real, useful resource; not-found for a missing resource; and a direct redirect only when a clear replacement exists.

More notes

Related diagnostic paths

Verify Googlebot requests before using crawl data

Verify claimed Googlebot traffic with trusted source IPs, Google IP ranges or DNS, exact route evidence, and explicit limits before using crawl data.

Read note →

Crawl requests do not prove indexation

Separate discovery, verified crawl requests, fetched output, index selection, search visibility, and business outcomes when reading crawler logs.

Read note →