Why Query Parameters After Hash Symbols Don’t Exist to Google
On this page
- The mechanism: fragments are client-side only
- Confirm how your filter state is encoded
- Move state into real URLs with the History API
- Query string versus path, and what each is for
- Render and scope deliberately
- A quick worked check
- Frequently Asked Questions
- Google renders JavaScript now, so why can’t it index my hash-based filters?
- If I redirect old hash URLs to query-string URLs in JavaScript, does that fix SEO?
- Sources
- Related posts:
Everything after the # in a URL is a fragment, and the browser never sends it to the server. So when your filter or state lives after the hash, /properties#bedrooms=3 and /properties#city=seattle both reach the server as exactly /properties, and Google requests and indexes only that base URL. Every hash variation collapses into the same page.
Hash-routed filtered views and stateful pages are invisible to search no matter how good their content is, because the routing key that produces them never arrives at the server or at Googlebot. The fix is to put state in real server-addressable URLs, query parameters or path segments via the HTML5 History API, and to server-render only the high-demand combinations.
This is a property of HTTP and the URL specification, not a Google limitation and not a rendering problem. “But Google renders JavaScript” does not rescue you here, because the issue happens before any rendering: the hash was never part of the request.
The mechanism: fragments are client-side only
Per the URI specification, the fragment identifier (the part after #) is stripped 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 to know what fragment the browser is holding, because it was never transmitted. Googlebot, which is an HTTP client, is in the same position: it requests /properties and receives the response for /properties, with no knowledge that a hash existed.
This breaks hash-based JavaScript routing specifically. A single-page app that reads window.location.hash to decide what to render works fine in a user’s browser, where the hash is present in the address bar. But in Google’s fetch-and-render environment the requested URL had no hash, so window.location.hash is empty, and the app renders its default, unfiltered state. That default state is what gets indexed for every hash variation, which is to say the filtered content effectively does not exist in search.
Confirm how your filter state is encoded
The diagnosis is fast: look at how a filtered view is encoded in the URL. If selecting “3 bedrooms” produces /properties#bedrooms=3, the state is after the hash and is non-indexable. If it produces /properties?bedrooms=3 (a query string) or /seattle/3-bedroom (a path), the state is part of the URL the server receives and is therefore crawlable and indexable. The presence of the # before your parameters is the whole tell.
A historical dead end to rule out: the escaped-fragment scheme, where sites exposed #! URLs and served Googlebot a pre-rendered snapshot via an escapedfragment parameter. Google deprecated that scheme on October 14, 2015 and recommends progressive enhancement instead. Do not build around escaped fragments; they are not a current option.
Move state into real URLs with the History API
The fix is the HTML5 History API. Using pushState and replaceState, a JavaScript app can change the URL to a real path or query string, /properties?bedrooms=3 or /seattle/3-bedroom, without a full page reload, so you keep the single-page-app feel while emitting URLs the server and Googlebot actually receive.
This is already the default routing mode in modern frameworks: React Router and Vue Router use History-API routing out of the box, with hash routing as a legacy fallback. If your app is on hash routing, switching the router’s mode is the core change.
A migration caveat that catches people: you can write client-side JavaScript that redirects an old #-bookmark to its ?-equivalent so existing user bookmarks keep working, but that does nothing for SEO. Google never requested the # URL, so there is no crawl to redirect. What actually gets the new URLs indexed is linking to them internally and listing them in your sitemap, so Google discovers and fetches them as the real URLs they now are.
Query string versus path, and what each is for
Once you accept that state must live somewhere the server receives, there are two server-addressable options, and the choice has consequences. A query string (/properties?bedrooms=3&type=condo) is the natural fit for arbitrary, combinable filters, and it is fully crawlable; Google long ago stopped needing any special configuration to crawl parameterized URLs, and the old Search Console URL Parameters tool that used to manage them was itself retired. A path segment (/seattle/3-bedroom-condos) reads as a more deliberate, canonical landing page and tends to suit the high-value combinations you want to treat as standalone pages with their own content and links.
A reasonable pattern is to use clean path-based URLs for the handful of combinations you have decided to target as indexable landing pages, and query strings for the long tail of filter states that exist for users but that you do not want competing in the index. Either way, the deciding property is the same one this whole problem turns on: the value reaches the server. The hash never did, which is why no amount of clever client-side handling makes hash-routed state indexable.
Confirming which scheme your app uses is therefore the first and most important check, because it determines whether the content can be found at all before any optimization is worth doing. If the answer is that filters live after the hash, no amount of rendering work, schema, or internal linking will help until the routing itself moves to a server-addressable scheme, so that migration is the prerequisite for everything else.
Render and scope deliberately
Two disciplines keep the migration from creating a new problem. First, prefer server-side rendering for the indexable filtered pages. With the content in the initial server response (for example via getServerSideProps in Next.js or the equivalent in your framework), Google sees the filtered content immediately and you avoid relying on the render budget to fill in JavaScript-dependent content. Real URLs that still render their unique content only on the client reintroduce render uncertainty.
Second, do not generate a page for every filter combination. Faceted filters multiply into an enormous space of permutations, most with no search demand, and indexing all of them creates a combinatorial explosion of near-empty, near-duplicate URLs that dilutes crawling. Use keyword research to identify the combinations people actually search, three-bedroom homes in a specific city, a popular price-and-type pairing, and expose indexable, server-rendered URLs for those only. The rest can stay as client-side state behind query parameters that you choose not to surface in internal links or the sitemap.
For the long-tail filter states you do not want competing in the index, the clean way to keep them out is a canonical tag pointing back to the core page, or simply not linking to them and leaving them out of the sitemap so they are never discovered. Reserve a noindex directive for combinations that do get discovered but should not rank. The distinction matters: a canonical consolidates signals to the page you do want indexed, while a noindex removes a page from the index outright, and using the wrong one either wastes the equity of the filtered view or fails to suppress a page you meant to hide.
A quick worked check
Run the diagnosis on your own app in under a minute. Open a filtered view, copy the URL from the address bar, and paste it into a fresh incognito tab. If the page loads with the filter applied, the state lives in a query string or path and the server received it. If it loads in its default, unfiltered state, the filter lived after the hash and was lost the moment the request left the browser, which is exactly what Googlebot experiences. That single check tells you whether the content is addressable before you invest in any further optimization.
Frequently Asked Questions
Google renders JavaScript now, so why can’t it index my hash-based filters?
Because the problem happens before rendering. The fragment after # is never sent to the server, so Googlebot’s request is for the base URL only, and the app’s code that reads the hash sees nothing. Rendering JavaScript does not recover a value that was never part of the request. You need the state in a query string or path instead.
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 is no server request to redirect and no crawl signal to recover. The client-side redirect only helps real users with old bookmarks. To get the new URLs indexed, link to them internally and include them in your sitemap so Google discovers and crawls them.
Sources
Google Search Central Blog, “Deprecating our AJAX crawling scheme” (Oct 14, 2015): https://developers.google.com/search/blog/2015/10/deprecating-our-ajax-crawling-scheme
MDN Web Docs, “Working with the History API” (pushState/replaceState): https://developer.mozilla.org/en-US/docs/Web/API/HistoryAPI