The # in a hash router is a giveaway that you're faking navigation. The History API removes it: history.pushState changes the URL to a clean /users/42 and adds a history entry without a page load, and the popstate event tells you when the user hits back or forward. That's how modern SPA routers (React Router, Vue Router) produce real-looking URLs. The catch — and the whole lesson here — is that pushState is silent: it fires no event, so your own navigations must resolve manually.
Implement historyRouter() with on, subscribe, start, navigate, and stop. See MDN: History API.
function historyRouter() {
return { on, subscribe, start, navigate, stop };
}
const router = historyRouter()
.on('/users/:id', (params) => render(User, params))
.on('/', () => render(Home));
router.start(); // resolves the current pathname
router.navigate('/users/42'); // pushState -> URL is '/users/42' (no #), then resolve
// user clicks Back -> popstate -> router resolves '/' -> Home
location.pathname (/users/42), no #; an empty path means /.:name segments are params — decode the captured values, like the hash router.pushState fires nothing — after history.pushState, call resolve yourself; only back/forward fire popstate.stop() removes the popstate listener. First match wins; unmatched navigations still notify subscribers with matched: false.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.