Skip to content
Issue docs

Internal links using hash-bang (#!) URLs

Standardhashbang_url_linkIssue 200

What is this issue?

The page links to other content on your site through hash-bang addresses — URLs where the route comes after #!, such as https://example.com/#!/products.

Hash-bang URLs come from older single-page applications. Everything after # is a fragment, which a browser never sends to the server. A script reads the fragment and draws the matching screen. Google once had a special scheme that treated #! as a separate page; it retired that scheme in 2015.

So to a search engine, /#!/products, /#!/about and /#!/contact are all the same URL — / — with different fragments.

For a page to pass this check:

  • No internal link uses #! in its address.

Ordinary fragments that point at a section of a page (/guide#install) are not affected.

Why it matters

  • The routes are not separate pages to a search engine. Every #! address collapses onto the URL before the #. At best one of those screens is indexed; the rest cannot rank, because as far as search is concerned they do not exist.

  • The special treatment ended years ago. Google's AJAX crawling scheme, which mapped #! to ?_escaped_fragment_=, was deprecated in 2015. Sites still using it depend on a rule nothing honours any more.

  • Links to these routes are wasted. Internal links are how pages are discovered and how importance is passed around a site. A link into a #! route passes all of that to the homepage instead.

  • They cannot be shared reliably. Many social platforms and tools strip fragments, so a shared #! link opens the wrong screen.

Effect on the health score

This is a standard-severity issue in the Crawlability lens, raised once per page however many hash-bang links the page carries.

How to fix it

  1. Switch the router to real paths (the History API). Every modern framework router supports it — usually one setting:

    // Vue Router
    createRouter({ history: createWebHistory(), routes })   // not createWebHashHistory()
    
    // Angular
    RouterModule.forRoot(routes)                            // not { useHash: true }
    
    // React Router
    <BrowserRouter>                                         // not <HashRouter>
  2. Serve each path from the server. With real paths, a visitor can land on /products directly, so the server must answer every route with the app (or, better, with server-rendered HTML for that route).

  3. Update the links.

    <!-- Before -->
    <a href="/#!/products">Products</a>
    
    <!-- After -->
    <a href="/products">Products</a>
  4. Redirect the old addresses. The server never sees a fragment, so the old #! links cannot be redirected with a 301. Add a small script on the root page that reads location.hash and replaces it with the matching path, so old bookmarks and shared links keep working.

Examples

Example 1: Real paths

Passes because: each route is its own URL.

<a href="/products">Products</a>
<a href="/about">About</a>

Example 2: A hash-bang navigation

Fails because: to a search engine all three links point at /.

<a href="/#!/products">Products</a>
<a href="/#!/about">About</a>
<a href="#!/contact">Contact</a>

Corrected version:

<a href="/products">Products</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>

Not reported: a fragment that points at a section of a page is what fragments are for.

<a href="/guide#install">Installation</a>

How PixyScan detects this

  1. Reads every <a href> on the page from the HTML the server sent, BEFORE anything normalises it. Link addresses are stored without their fragment, and the fragment is where #! lives — so it has to be caught at extraction.

  2. Reports an internal link whose href contains #!: an absolute address on this site (https://example.com/#!/products), a root-relative one (/#!/about), or a route on the current page (#!/contact).

  3. Ignores links to other domains, ordinary fragments (#section), hash-router paths without the ! (#/settings), and a ! anywhere outside the fragment.

  4. Raises one finding per page with the number of such links and up to five of their hrefs exactly as written.

The link itself is still recorded in the site's link graph, as a link to the address before the # — which is precisely what a search engine sees too.

What we store

Storage Level

Page Level


Database Table / Prisma Model

audit_issues.details

Decided at extraction, from the raw href. The link row in internal_links is stored without the fragment, as a link to the address before the #.


Stored Fields

Field Type Description
message String "Found N internal link(s) using hash-bang (#!) URLs"
hashbangLinkCount Int Internal anchors on the page whose href contains #!
samples String[] Up to five of those hrefs, exactly as written (cut at 300 characters)

Detection Dependencies

  • HTML Document — every <a href>, read before the fragment is stripped

Further reading