v0.1.0Zero dependencies · 8.9 kB gzip · any renderer

Client routing with great flexibility and observability that

is awaitable guards before history writes has back/forward vetoes that heal ensures the latest navigation wins works with any renderer you inject

defuss-dom-router turns URLs into immutable route requests and orders navigations against the browser's real History API. Your prepare() and commit() callbacks do the rendering, with plain DOM, defuss-query, defuss-morph or any other renderer. Every navigate() resolves to exactly one outcome, so you never have to guess whether the screen changed.

Built by Aron Homberg (kyr0) (opens in a new tab) – Open Source (MIT (opens in a new tab))

Awaitable outcomes

await router.navigate() resolves to committed, unchanged, blocked, superseded, rejected or error. Failures settle as values, never as unhandled rejections.

Guards before history

Async guards and beforeLeave run before pushState, so a veto leaves history untouched. A vetoed Back or Forward is walked back with history.go(), and the forward stack survives.

The latest navigation wins

A newer navigation abandons one that is still preparing and disposes its view before anything changes on screen. Commits are serialized and never interleave.

Bring your own renderer

prepare() builds the next view detached from the page, commit() puts it on screen. Native DOM, defuss-query, defuss-morph or shadcn's df$: the router imports none of them.

One file, zero dependencies

One self-contained ESM file of 32.6 kB (8.9 kB gzip) that imports nothing, so it runs as a plain <script type="module">. Importing it starts nothing and attaches no listener.

Strict by default

Only credential-free HTTP(S) URLs on your origin and inside basePath are routed. Navigation state must be finite JSON. Diagnostics carry a code and fixed text, never the URL.

Navigate.

Guard.

Commit.

Live demo

🧭 A routed app, with its devtools open

Atlas is a small project tracker routed by the shipped bundle in hash mode. It runs in a frame, so its router owns the frame's Window and the real History API, not this page's. Click around, edit a title and leave, race two navigations or press Back. The panel on the right is built only from the public API: subscribe(), a guard and the callbacks.

The frame shares this tab's session history, so your browser's own Back button drives the demo too.

How it works

💡 One coordinator orders every navigation

URL parsing and matching are pure functions. One coordinator orders the intents, runs the guards and your prepare(), writes or adopts the history entry and only then calls commit(). Every entry the router writes carries a session ID and an index, which is how a vetoed Back or Forward finds its way back.

navigate(), link or popstate
PureResolveURL → RouteRequest, JSON state copied
OrderingAbort the older intentonly while it is unlocked
Guards allow?
blockedbefore-write: history untouched
history.go(delta)owned-history-restored
Your codeprepare()detached view · AbortSignal
LockWrite or adopt historypushState / replaceState
Your codecommit()serialized, never interrupted
EffectsDispose · afterCommit · scrolloutgoing view released
committed
  1. false · navigate or link
  2. false · Back/Forward
  3. allowed

Six outcomes, one per navigation

  • committed

    History was written or adopted, and the prepared view, if any, committed.

    { status, intentId, request }
  • unchanged

    Same href and state as the current entry, and no failed navigation to recover from. Nothing ran.

    { status, intentId, request }
  • blocked

    A global guard or beforeLeave returned exactly false. veto says whether history was untouched or restored.

    { …, veto: 'before-write' | 'owned-history-restored' }
  • superseded

    A newer navigation took over before this one locked. A view it had prepared was disposed, never shown.

    { status, intentId }
  • rejected

    Invalid input, or a router that is not started or was destroyed. Invalid input runs nothing and writes nothing.

    { status, intentId, error }
  • error

    A guard, prepare(), the history write, commit() or an effect failed.

    { …, error, commitStarted }

Intent ordering

  1. One active, one queued

    At most one intent is active and at most one waits. Everything else has already settled.

  2. Newer aborts the unlocked

    While the active intent guards or prepares, a new one aborts its AbortSignal: superseded, view disposed.

  3. Locked work finishes

    Once history is written and commit() started, new intents queue. A later one replaces the queued one, so the latest wins.

  4. Checked after every await

    The coordinator re-checks that its intent is still current after each await, also when a subscriber navigates from inside a snapshot callback.

resolve() and href()

🔎 Resolve URLs without a Window

Resolution is pure: resolve() and href() work from baseUrl alone, on a server, in tests or right here. This explorer runs the same bundle on a router that is never started, so it owns no history.

explorer.js
const router = createRouter({  mode: 'history',  basePath: '/app',  baseUrl: 'https://example.com/app/',  routes: [    { id: 'home', path: '/' },    { id: 'project', path: '/projects/:id' },    { id: 'file', path: '/files/:name.json' },    { id: 'api', path: '/api/v:version/*' },    { id: 'docs', path: '/docs/*' },    { id: 'missing', path: '*' },  ],}); // never started: no Window, no history

router.href(target)

API

📚 Tiny API surface, but powerful features

One factory, createRouter(config), and the RouterInputError class. The full declarations ship as dist/index.d.ts; the source of truth is src/types.ts.

createRouter(config: RouterConfig): Router validates the configuration synchronously and copies it. Afterwards only the callbacks can change, through config(); new routes or a new mode need a new router.

OptionTypeDefaultMeaning
routes{ id, path }[]requiredBase-relative patterns. The first registered match wins.
mode'history' | 'hash''history'In hash mode the page path stays fixed and #/path?query#anchor is the route, so any static server answers deep links.
basePathstring'/'The origin-rooted, URL-normalized directory the app lives under.
baseUrlstringthe Window locationBase for relative resolution before start() or without a Window. HTTP(S) only.
windowWindowglobalThis.windowResolved lazily at start(): importing and constructing attach no listener.
scroll'auto' | 'manual''auto'auto scrolls to anchors after commit and restores saved positions on Back/Forward. manual turns both off.
prepare(ctx) => PreparedViewa no-op viewBuild the next view detached from the page; fetch with ctx.signal. Must not change the live page. May be async.
afterCommit(ctx) => void–Title, focus and ARIA updates after the commit. Respect ctx.cause and existing user focus.
onError(error, snapshot) => void–Typed, serializable { code, message } diagnostics.
Route patterns

Patterns are full matches with :named segments, embedded parameters such as /api/v:version, one terminal /* wildcard (its value is named wildcard) and an optional trailing slash. A lone * catches everything. Duplicate IDs, patterns or parameter names and dot segments fail at construction. Parameters are decoded once, so an encoded %2F stays data and never becomes route structure.

Views: prepare, commit, beforeLeave, dispose

prepare(ctx) returns { commit(), beforeLeave?(), dispose?() }. A superseded view is disposed without committing. Once commit() starts it is never interrupted or rolled back, and a failed commit is reported as an error. beforeLeave runs when the route ID or path changes; query-only changes prepare a new view without stacking leave hooks, and anchor-only changes skip rendering. Callbacks must not await navigation or destroy() of their own router while it commits, because that would wait on itself.

History state

Navigation state must be finite JSON; the router copies and freezes it. Other fields you keep in history.state survive. Do not call pushState/replaceState yourself while a router owns the Window: such entries carry no router metadata, so a vetoed Back onto them cannot be corrected. One router owns one Window; a second fails with already-owned until the first is destroyed.

Small, and measured

Sizes are make metrics output for the 0.1.0 build of dist/index.js, the file this page and the demo load. make lint fails when the bundle imports another module or grows past 14,000 bytes gzip.

8,928 B gzip7,863 B Brotli, 32,562 B raw: one ESM file
0runtime or peer dependencies, enforced by the lint policy
6navigation outcomes; every navigate() settles to exactly one
3browser engines in the release matrix: the browser suite runs against the real History API in Chromium, Firefox and WebKitThe browser suite
make metrics
index.js 32562 bytes 8928 gzip 7863 brotli
index.cjs 32616 bytes 8952 gzip 7875 brotli
grep -c "^import" dist/index.js
0
Measured with zlib gzip level 9 and Brotli defaults.
Install

🧑‍💻 Add it to your app

Build it from source, or take the one built file. Then write a prepare(), or hand one prompt to your coding agent.

Not on npm yet

0.1.0 is not published to a registry. Requirements for building: Bun 1.4.2 (pinned in packageManager) and Node.js.

From source

Clone and build once, then add the checkout to your app. make build runs pkgroll and writes ESM, CommonJS and declarations to dist/.

git clone https://github.com/kyr0/defuss-dom-router.gitcd defuss-dom-routerbun install --frozen-lockfile && make build
bun add /path/to/defuss-dom-router

One file

dist/index.js imports nothing, so it runs as a plain browser module without a bundler. This site serves a byte-for-byte copy of it.

defuss-dom-router.js 32,562 B · ESM
index.html
<script type="module">  import { createRouter } from './defuss-dom-router.js';</script>

Render routes

prepare() builds the view without touching the page; commit() puts it on screen. Served at /projects/42, the outlet shows Project 42; a click on <a data-router-link href="/projects/7"> shows Project 7, and Back shows Project 42 again, all in one document load. Clean paths need your server to answer them with the same HTML; with mode: 'hash' any static server is enough.

import { createRouter } from './assets/defuss-dom-router.js';const outlet = document.querySelector('main');if (!outlet) throw new Error('A main outlet is required');const router = createRouter({  routes: [{ id: 'project', path: '/projects/:id' }, { id: 'missing', path: '*' }],  prepare({ to }) {    const heading = document.createElement('h1');    heading.textContent = to.match && to.routeId === 'project'      ? `Project ${to.params.id}` : 'Not found';    return { commit() { outlet.replaceChildren(heading); } };  },});router.attachLinks(document);await router.start();
// The router imports neither; call them inside commit(). Their html()/morph() take// markup or VNodes, not a DOM element, so serialize what you built.// With defuss-shadcn's core loaded, reuse its globalThis.df$ instead of a second engine.prepare({ to }) {  const heading = document.createElement('h1');  heading.textContent = to.routeId === 'record' ? `Record ${to.params.id}` : 'Not found';  const markup = heading.outerHTML;  return { async commit() { await df$('#route-view').html(markup); } };}
import { createRouter } from 'defuss-dom-router';const router = createRouter({  baseUrl: 'https://example.test/app/',  basePath: '/app',  routes: [{ id: 'project', path: '/projects/:id' }, { id: 'missing', path: '*' }],});const to = router.resolve('/app/projects/42?tab=timeline&tab=files#event-7');console.log(to.routeId, to.params, to.query, to.hash);console.log(router.href({ id: 'project', params: { id: 'a b' }, query: [['tab', 'files']] }));// project [Object: null prototype] { id: '42' } [ [ 'tab', 'timeline' ], [ 'tab', 'files' ] ] #event-7// /app/projects/a%20b?tab=files

Vibe coding

Paste this prompt into your coding agent. It links the repository, so the agent works from the current README and the bundled SKILL.md.

Integrate defuss-dom-router into this app: https://github.com/kyr0/defuss-dom-router
Read its README.md and SKILL.md first. It is not on npm: build it from a checkout (bun install, make build) and import createRouter from the package or a copied dist/index.js.
Register routes and guards before start(). Build each screen in prepare() without touching the live page, put it on screen in commit(), and await every navigation result, including blocked, superseded and error.
Use real <a data-router-link> anchors with hrefs from router.href(). Use mode: 'hash' only when the server cannot answer deep links with the app's HTML.
⏎ send

Give your agent the defuss-vae skills too: it then plans, tests and reviews against a verifier instead of declaring itself done. In Claude Code, the plugin adds the commit and Stop-hook gate. Needs python3 ≥ 3.9, git and make.

npx skills add kyr0/defuss-vae --skill '*'
claude plugin marketplace add kyr0/defuss-vaeclaude plugin install defuss-vae@defuss-vae
Portrait of Aron Homberg

Stressed with inconsistent results and tiring reviews?

Build more consistent agents with defuss-vae (opens in a new tab), Aron Homberg’s Verified Agentic Engineering (opens in a new tab) method. Aron is a freelance AI researcher, O’Reilly author (2011), conference speaker, and longtime mentor to software engineering teams.

Talk to Aron on LinkedIn (opens in a new tab)

...if you're looking for a remote training or mentoring session for you or your engineering team.

Route with plain URLs and the renderer you already have

Awaitable results, guards before history writes and Back/Forward vetoes that heal, in one file without dependencies.

MIT licensed · 0.1.0 · zero runtime dependencies