# Hash routing Your SPA uses fragment URLs like /#/page. The fragment never reaches the server, so crawlers see one URL and one HTML body regardless of how many routes your app actually ships. By AgentSite · 4 min read · Updated 2026-05-24 Hash routing is the SPA pattern where URLs look like `https://example.com/#/products/widget` instead of `https://example.com/products/widget`. The fragment — everything after the `#` — is a client-only handle. The server sees the same URL for every route on the site. AI crawlers indexing those URLs see a single page where the app has dozens. ## What it looks like The library defaults that produce this pattern: - **Vue Router in hash mode** (`createWebHashHistory()` instead of `createWebHistory()`). - **React Router with `HashRouter`** instead of `BrowserRouter`. - **Legacy AngularJS apps** built before `$locationProvider.html5Mode(true)` was the default. - **Static-host setups** where `pushState` URLs would 404 on refresh because the host has no fallback rewrite, so the team flips to hash mode as a workaround. In every case the surface symptom is the same: every page on the site is served from a single server-side URL. A user navigating `/#/about` → `/#/pricing` → `/#/blog/launch` is rewriting only the fragment, never issuing a server request that includes the path. ## How to detect it Three checks: ```bash # 1. Look at any link in your nav or sitemap. Does the URL contain a '#'? curl -sS https://example.com/ | grep -oE 'href="[^"]*"' | grep '#' # 2. Fetch the supposed product page directly. Compare to the homepage. diff \ <(curl -sS https://example.com/) \ <(curl -sS 'https://example.com/#/products/widget') ``` If the `diff` returns nothing — both responses byte-identical — the route after the `#` is invisible to the server. That's hash routing. The protocol-level confirmation is in [RFC 3986 §3.5](https://www.rfc-editor.org/rfc/rfc3986#section-3.5): "the fragment identifier is not used in the scheme-specific processing of a URI; instead, the fragment identifier is separated from the rest of the URI prior to a dereference." The user agent strips it before the HTTP request goes out. Servers, CDNs, and crawlers that fetch via HTTP never see it. ## Why it costs you citations Every route on the site collapses to the same URL in the citation pool. An AI engine that wants to cite your guide on widgets can only cite your homepage — there is no other URL to point at. The content for the widget guide exists in your codebase but doesn't exist in any form a citation system can reference. The deeper problem stacks with [SSR-junk and bot walls](/ssr-junk-bot-wall). Major AI crawlers do not execute JavaScript — Vercel measured 569 million GPTBot fetches and 370 million Claude fetches in a single month, none of them rendering ([Vercel, Dec 2024](https://vercel.com/blog/the-rise-of-the-ai-crawler)). A hash-routed SPA gives a non-rendering crawler the worst possible signal: one URL, one HTML body (probably empty), no way to discover the rest of the app. Even a crawler that _did_ render JS could not produce stable citation URLs, because the URLs it would emit (`https://example.com/#/foo`) are not the URLs end-users land on when they click through from a chat interface — those land back at the homepage and the user has to navigate manually. Google deprecated its old AJAX-crawling workaround for hashbang (`#!`) URLs in 2015 ([Google Search Central, Oct 2015](https://developers.google.com/search/blog/2015/10/deprecating-our-ajax-crawling-scheme)) and recommended `pushState` URLs as the replacement. The crawlers that came later — including the AI crawlers — were built against the post-2015 expectation that real URLs use the history API, not fragments. There is no fallback path being maintained for hash routing today; the pattern is unsupported by the consumer the page is trying to reach. ## How to fix it The fix is a library-level configuration change plus a server-side rewrite: 1. **Switch the router to history-API mode.** In Vue: replace `createWebHashHistory()` with `createWebHistory()`. In React Router: replace `HashRouter` with `BrowserRouter`. In AngularJS: enable `$locationProvider.html5Mode(true)`. 2. **Add a server-side rewrite so unknown paths serve `index.html`.** Without this, a refresh on `/products/widget` 404s because no file exists at that path. On Vercel/Netlify/Cloudflare Pages this is a single rewrite rule. On Nginx it's a `try_files $uri /index.html` fallback. On a CDN it's a custom origin rule. 3. **Add a sitemap.xml that lists the real URLs.** Once history-API URLs work, the routes are addressable and indexable — a sitemap makes the inventory discoverable to a crawler that doesn't render JS. 4. **Set up redirects from old hash URLs to the new path URLs.** A static `index.html` redirect snippet that reads `window.location.hash` and 301-equivalents to the path version. Optional but worth the effort if any of the hash URLs have inbound links. The combination removes the SPA-only failure mode without changing the application code beyond the router config. The server-rendered-HTML problem at Layer 1 ([SSR-junk and bot walls](/ssr-junk-bot-wall)) is a separate, larger fix; hash routing is the prerequisite that has to be solved first so there's somewhere for the rendered HTML to live. ## Where AgentSite fits The AgentSite render service can't fix hash routing — the URLs the crawler fetches simply don't contain the route, so there is nothing for the middleware to render or augment. Customer sites that ship hash URLs receive a Layer-1 warning in the AEO Report flagging that the citation pool is collapsed to a single URL. The architectural fix above is required at the customer's origin before the render service can help. Once the router is on the history API, the AgentSite middleware renders and caches the SPA's routes per-URL the same way it would for any other client-rendered site. ## Related problems - [SSR-junk and bot walls](/ssr-junk-bot-wall) — the broader Layer-1 failure family; hash routing is the variant where there's no URL to render against in the first place. - [Soft 404](/soft-404) — the related "wrong HTTP signal for SPA routes" failure where the URL does exist but the status code lies. - [The five layers of AEO](/five-layer-aeo) — the structural map this problem sits in (Layer 1). - The full catalog: [AEO problems](/aeo-problems).