Choosing Between Hash and pushState Routing in Backbone.js
A decision guide for choosing between hash-based and pushState routing in Backbone.js, with a comparison table, trade-offs, and a minimal working Express example you can verify in five minutes.
17 Jun 2026, 18:46 UTC

The Decision: Clean URLs vs. Zero-Config Deployment
Backbone.js gives you two ways to handle client-side navigation: hash-based routing (the default) and HTML5 pushState routing. The choice affects your server configuration, SEO, browser history behavior, and how users share links. This guide walks through the trade-offs, shows a compact comparison, and provides a minimal working example you can verify in five minutes.
Quick Comparison
| Factor | Hash Routing (#/route) | pushState Routing (/route) |
|---|---|---|
| Server config required | None — works with any static host | Catch-all route serving index.html for every client path |
| URL appearance | Visible # fragment | Clean, standard path |
| Direct link / refresh | Always works | Fails without server fallback |
| SEO / social sharing | Fragments ignored by some crawlers | Full URLs indexed normally |
| Browser support | All browsers including IE9 | IE10+; Backbone falls back to hash automatically if unavailable |
| Analytics integration | Requires manual hashchange tracking | Standard popstate events |
Constraints That Drive the Choice
Pick hash routing when:
- You deploy to a static host with no rewrite rules (GitHub Pages, S3 static site, Netlify without _redirects).
- You need IE9 support without polyfills.
- The app lives under a path you don't control (e.g.,
/legacy/app/on a shared domain).
Pick pushState when:
- You control the web server (nginx, Apache, Node, Python) and can add a fallback rule.
- Clean URLs matter for branding, SEO, or user trust.
- You're doing server-side rendering (SSR) and need the initial HTML to match the client route.
Route Definition Patterns
Both modes use the same route map. Keys are patterns; values are callback names (strings) or functions. Order matters — first match wins.
var AppRouter = Backbone.Router.extend({
routes: {
'': 'home',
'users/:id': 'userDetail',
'search/*query': 'searchResults',
'*notFound': 'notFound'
},
home: function() { /* ... */ },
userDetail: function(id) { /* id is a string */ },
searchResults: function(query) { /* query includes leading slash */ },
notFound: function() { /* catch-all */ }
});
:param captures a single segment; *splat captures the rest of the path including slashes. Use router.route(pattern, name, callback) to add routes dynamically after initialization.
Concrete Implementation: pushState with a Node/Express Fallback
This minimal example shows a working pushState setup. You'll need Node 18+ and a terminal.
1. Project Structure
project/
├── public/
│ ├── index.html
│ └── app.js
├── server.js
└── package.json
2. package.json
{
"name": "backbone-pushstate-demo",
"private": true,
"dependencies": {
"express": "^4.18.2",
"backbone": "^1.4.1",
"jquery": "^3.7.0",
"underscore": "^1.13.6"
}
}
Run npm install in the project root.
3. public/index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Backbone pushState Demo</title>
<base href="/">
</head>
<body>
<nav>
<a href="/" data-nav>Home</a> |
<a href="/users/42" data-nav>User 42</a> |
<a href="/search/backbone/router" data-nav>Search</a>
</nav>
<main id="app"></main>
<script src="/app.js"></script>
</body>
</html>
The <base href="/"> tag ensures relative asset paths resolve correctly when the browser thinks it's at /users/42.
4. public/app.js
var $ = require('jquery');
var Backbone = require('backbone');
Backbone.$ = $;
var AppRouter = Backbone.Router.extend({
routes: {
'': 'home',
'users/:id': 'userDetail',
'search/*query': 'searchResults',
'*notFound': 'notFound'
},
home: function() { this.render('Home view'); },
userDetail: function(id) { this.render('User ' + id); },
searchResults: function(query) { this.render('Search: ' + query); },
notFound: function() { this.render('404 — Not found'); },
render: function(text) {
$('#app').html('<p>' + text + '</p>');
}
});
var router = new AppRouter();
// Enable pushState with root '/'
Backbone.history.start({
pushState: true,
root: '/',
silent: false // let the initial route fire
});
// Delegate navigation clicks to router
$(document).on('click', 'a[data-nav]', function(e) {
e.preventDefault();
var href = $(this).attr('href');
router.navigate(href, { trigger: true });
});
5. server.js — Express with Catch-All
const express = require('express');
const path = require('path');
const app = express();
// Serve static assets
app.use(express.static(path.join(__dirname, 'public')));
// Catch-all: return index.html for any non-asset route
app.get('*', (req, res) => {
// Avoid catching API routes or static files with extensions
if (req.path.startsWith('/api') || path.extname(req.path)) {
return res.status(404).send('Not found');
}
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
Run node server.js and open http://localhost:3000. Click the links — the URL changes without a page reload. Refresh on /users/42 — the server returns index.html, Backbone boots, and the route fires.
Hash Mode Equivalent
To switch to hash routing, change only the Backbone.history.start() call:
Backbone.history.start({ pushState: false }); // default, explicit for clarity
Remove the catch-all route from server.js — static hosting works as-is. Links in the HTML become href="#/users/42" (or keep href="/users/42" and let the click handler convert). The <base> tag is unnecessary.
Server-Side Rendering Compatibility
If you render the initial view on the server (e.g., with a template engine), you must prevent Backbone from firing the route a second time on the client:
// Client entry after SSR hydration
Backbone.history.start({ pushState: true, root: '/', silent: true });
// Optionally trigger manually if you need route callbacks to run
// Backbone.history.loadUrl();
The silent: true option skips the initial route event. Call Backbone.history.loadUrl() later if your route callbacks perform side effects (data fetching, analytics) that must run on first load.
Verification Checklist
- Clean URLs: Click a link; address bar shows
/users/42(pushState) or#/users/42(hash). - Refresh survival: Press F5 on a client route. pushState requires the catch-all; hash always works.
- Direct access: Paste
http://localhost:3000/search/backbone/routerinto a new tab. Same requirement as refresh. - Back/Forward buttons: Navigate back — the view updates without reload.
- Analytics hook: Listen to
router.on('route', fn)orrouter.on('all', fn)to track pageviews.
Common Pitfalls
- Calling
Backbone.history.start()twice throws"Backbone.history has already been started". Usesilent: trueon subsequent calls only if you intentionally suppress the initial route. - Missing
<base>tag breaks relative asset paths (CSS, images) when pushState makes the browser think it's in a subdirectory. - Catch-all too greedy serves
index.htmlfor missing images or API calls, hiding 404s. Filter by extension or prefix as shown inserver.js. - Route order: a
*notFoundroute placed before specific routes will match everything. Keep catch-all last.
Limitations
- Backbone's router is intentionally minimal — no nested routes, lazy loading, or built-in route guards. For complex apps, consider a dedicated router (e.g.,
director,page.js) or a framework with richer routing. - pushState fallback on IE9 is automatic but results in hash URLs; users on old browsers get a different URL format.
- Regex-based matching can slow down with dozens of complex patterns. Keep route definitions simple and specific.
How to Verify Your Setup
Open DevTools → Network tab. Navigate via links: you should see only XHR/fetch requests, no document reloads. Then refresh on a client route: the first request should be index.html (status 200), not a 404. If you see a 404, your server fallback is missing or misconfigured.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.