A modern, typed, framework-agnostic wrapper over the browser History API. The 2026 successor to browserstate/history.js.
The original history.js shipped in 2010 to paper over the HTML4/HTML5 split. Every browser worth supporting today implements pushState/popstate natively, so this rewrite drops the HTML4 fallback, the jQuery/MooTools/Prototype adapters, and the global History namespace — and gives you what apps actually want now: typed entries, async navigation guards, query helpers, route matching, link interception, and a virtual stack.
npm install @buildwithdarsh/historyjs
# or
pnpm add @buildwithdarsh/historyjs
# or
yarn add @buildwithdarsh/historyjsOr via CDN:
<script src="https://unpkg.com/@buildwithdarsh/historyjs"></script>import { getHistory } from '@buildwithdarsh/historyjs';
type AppState = { view: 'home' | 'profile'; userId?: string };
const history = getHistory<AppState>();
history.subscribe((event) => {
console.log(event.type, '→', event.to.location.pathname, event.to.state);
});
await history.push('/profile/42', { state: { view: 'profile', userId: '42' } });
history.back();Original history.js (2010) |
@buildwithdarsh/historyjs (2026) |
|---|---|
| HTML4 hashchange fallback | Native History API only |
| jQuery / MooTools / Prototype adapters | Zero dependencies |
Global History namespace, untyped state |
TypeScript-first, generic state |
Event-name strings (statechange) |
Typed NavigationEvent callbacks |
| No navigation guards | Async guards with cancellation |
| Manual query/hash building | setQuery, setHash, getQuery |
| No routing primitives | Built-in pattern matcher |
Manual <a> interception |
interceptLinks(history) |
| Manual scroll handling | Built-in scroll restoration |
Every navigation produces a typed HistoryEntry<TState>:
{
id: string; // stable, survives reload
state: TState | null; // your typed state
title: string;
location: { pathname, search, hash, href, origin };
index: number; // monotonic
timestamp: number; // Date.now() at navigation
}await history.push('/path', { state: {...}, title: 'New title' });
await history.replace('/path');
history.back(); // or history.back(2)
history.forward();
history.go(-3);
history.reload();push and replace are async because guards may be async. They resolve to true if the navigation completed, false if it was cancelled.
const unsubscribe = history.subscribe((event) => {
// event.type: 'push' | 'replace' | 'pop'
// event.direction: 'forward' | 'backward' | 'none'
// event.from / event.to: HistoryEntry
// event.isPopState: true if triggered by browser back/forward
});
unsubscribe();Guards run before every navigation. Return false to cancel.
history.addGuard(async (event) => {
if (event.to.location.pathname.startsWith('/admin')) {
return await checkAuth();
}
});
// Or just prompt the user:
history.block('You have unsaved changes — leave anyway?');For pop navigations, a cancelled guard attempts to push the user back via history.go(delta).
history.setQuery({ page: 2, q: 'hello' }); // patch params
history.setQuery({ page: null }); // remove a key
history.getQuery('page'); // '2'
history.searchParams; // URLSearchParams (read-only)Or use the standalone helpers:
import { parseQuery, stringifyQuery, mergeQuery } from '@buildwithdarsh/historyjs';import { matchPattern, buildPath } from '@buildwithdarsh/historyjs';
history.matches<{ id: string }>('/users/:id');
// → { pattern: '/users/:id', path: '/users/42', params: { id: '42' } }
buildPath('/users/:id', { id: 42 });
// → '/users/42'Supported syntax:
:name— named param, matches a single segment:name?— optional segment:name*— splat, matches the rest including slashes
One call hijacks every same-origin <a> click. Modifier-clicks, off-origin links, download links, and links with target pass through unchanged.
import { interceptLinks } from '@buildwithdarsh/historyjs';
const off = interceptLinks(history);
// Optional config:
interceptLinks(history, {
selector: 'a[data-spa]',
root: document.getElementById('app'),
sameOriginOnly: true,
});history.entries is an in-memory snapshot of every entry the manager has visited — great for breadcrumbs, history menus, or analytics.
history.entries.forEach((entry) => {
console.log(entry.index, entry.location.href, entry.timestamp);
});history.metrics; // { pushes, replaces, pops, cancelled }Enabled by default on pop navigations. To opt out:
const history = getHistory({ restoreScrollOnPop: false });The library sets history.scrollRestoration = 'manual' by default — pass scrollRestoration: 'auto' or false to opt out.
getHistory() is a lazy singleton — convenient for app code. For tests or iframes, instantiate directly:
import { HistoryManager } from '@buildwithdarsh/historyjs';
const history = new HistoryManager({ window: iframe.contentWindow! });import { useEffect, useState, useSyncExternalStore } from 'react';
import { getHistory } from '@buildwithdarsh/historyjs';
const history = getHistory<{ page: string }>();
export function useHistoryEntry() {
return useSyncExternalStore(
(cb) => history.subscribe(cb),
() => history.entry,
);
}A full live playground (the page in example/index.html) is deployed at the project URL — push, replace, back/forward, query patching, route matching, guards, link interception, virtual stack, and a real-time event log are all wired up.
To run locally:
npm install
npm run build
npx serve examplenpm install
npm run typecheck # tsc --noEmit
npm test # vitest run
npm run build # rollup → dist/
npm run dev # rollup --watchnpm publish --access publicThe prepublishOnly script runs typecheck, tests, and the rollup build.
MIT