Why Query Parameters After Hash Symbols Don’t Exist to Google

On this page

Everything after the # in a URL is a fragment, and the browser doesn’t send it to the server. So when a filter lives after the hash, /properties#bedrooms=3 and /properties#city=seattle both reach the server as /properties, and that base URL is all Google requests. Every hash variation collapses into the same page.

Hash-routed filtered views are invisible to search however good their content is, because the routing key that produces them never arrives at the server or at Googlebot. The fix for the views you want in search is to put their state in real URLs, query parameters or path segments, using the History API, and to server-render the combinations people search for.

This comes from how URLs work, not from a Google limitation or a rendering problem. “But Google renders JavaScript” doesn’t help here, because the issue happens before any rendering: the hash was never part of the request.

The mechanism: fragments stay in the browser

The fragment identifier is removed from the request before it is sent. Its purpose was always client-side, originally to scroll to an anchor within a document. The server has no way of knowing which fragment the browser holds, because it was never transmitted. Googlebot is an HTTP client in the same position: it requests /properties and receives /properties.

That is what breaks hash-based routing. A single-page app that reads window.location.hash to decide what to show works in a user’s browser, where the hash is in the address bar. In Google’s fetch-and-render environment, the requested URL had no hash, so the app renders its default, unfiltered state, and that default is the only version Google sees for every hash variation.

Google’s guide to faceted navigation confirms it from Google’s side: fragments are generally left out of crawling and indexing.

Confirm how your filter state is encoded

The diagnosis is quick. Look at how a filtered view is encoded:

  • /properties#bedrooms=3: the state is after the hash and won’t be crawled.
  • /properties?bedrooms=3 or /seattle/3-bedroom: the state is part of the URL the server receives, so it can be crawled and indexed.

The # before your parameters is the whole tell.

One historical dead end to rule out: the old AJAX crawling scheme, with #! URLs and a pre-rendered snapshot served through an _escaped_fragment_ parameter. Google’s announcement of October 14, 2015 says it is no longer recommending the AJAX crawling proposal it made in 2009. Don’t build around it.

A fragment is a tool, not a bug

The same faceted navigation guide recommends fragments for filters you don’t want crawled: a filtering mechanism based on URL fragments has no impact on crawling. So the question isn’t “hash or no hash” for the whole app. It is which views you want in search. Keep the long tail of filter states in fragments if you like, and give real URLs to the views that deserve to be found.

Move indexable state into real URLs with the History API

The History API lets a JavaScript app change the address without a full reload. MDN’s guide to working with the History API describes the two methods: pushState() adds a new entry to the session history, and replaceState() updates the current one. With them, an app can move to /properties?bedrooms=3 or /seattle/3-bedroom while keeping the single-page feel, and emit URLs the server and Googlebot receive.

Front-end routers generally offer a History-API mode alongside a hash mode. If your app runs in hash mode, switching the router’s mode is the core change for the views you want indexed.

A migration caveat: a client-side script that sends an old # bookmark to its ? equivalent keeps users’ bookmarks working, but it does nothing for search. Google never requested the # URL, so there is nothing to redirect. What helps the new URLs get found is linking to them internally and listing them in your sitemap.

Query string or path

Once the state lives where the server can see it, there are two options:

  • A query string (/properties?bedrooms=3&type=condo) fits arbitrary, combinable filters and can be crawled.
  • A path segment (/seattle/3-bedroom-condos) reads as a deliberate landing page and suits the combinations you want to treat as standalone pages with their own content and links.

A workable pattern: clean paths for the combinations you target as indexable landing pages, query strings or fragments for the rest. Either way, the deciding property is the one this whole problem turns on: whether the value reaches the server.

Render and scope deliberately

Two disciplines help keep the migration from creating a new problem.

First, server-render the filtered pages you want indexed. With the content in the initial server response, Google sees it without depending on client-side rendering to fill it in. Real URLs whose unique content only appears after client-side JavaScript runs bring back rendering uncertainty.

Second, don’t create a URL for every combination. Filters multiply into more permutations than anyone searches for, and publishing all of them can create near-empty, near-duplicate URLs. Use keyword research to find the combinations people search, such as three-bedroom homes in one city or a popular price-and-type pairing, and give those server-rendered URLs. Keep the others out of internal links and the sitemap, or in fragments, so they’re harder to discover.

A one-minute check

Open a filtered view and look at the address bar. If the filter values sit after a #, the server never receives them, whatever the page shows in your browser. To see what Google gets, run the base URL through URL Inspection’s live test: Google requests only the part before the #, so the screenshot shows Googlebot’s view of every hash variation.

Frequently asked questions

Google renders JavaScript, so why can’t it index my hash-based filters?

Because the problem happens before rendering. The fragment isn’t sent to the server, so Google requests the base URL only, and the code that reads the hash finds nothing. Put the state you want indexed in a query string or path.

If I redirect old hash URLs to query-string URLs in JavaScript, does that fix SEO?

No. Google never requested the hash URL, so there’s nothing for it to follow. The redirect helps users with old bookmarks. To get the new URLs indexed, link to them internally and include them in your sitemap.

Should I remove every hash from my app?

No. Google’s faceted navigation guide recommends fragments for filters you don’t want crawled. Move to real URLs only the views you want people to find in search.

Leave a comment

Your email address will not be published. Required fields are marked *