Getting Started
Let's build your first SPA from scratch — a three-page app with working navigation in under 30 lines of code.
What we're building
A simple app with three pages: Home, About, and a 404 page. Clicking the nav links swaps the content without reloading the page.
Step 1: The HTML structure
You need three things in your HTML:
- A navigation bar with links
- An outlet
<div>where views will render <template>elements — one per "page"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My App</title>
</head>
<body>
<!-- Navigation -->
<nav>
<a data-route="/" data-route-active-class="active">Home</a>
<a data-route="/about" data-route-active-class="active">About</a>
</nav>
<!-- Outlet — views render here -->
<div id="app"></div>
<!-- Page templates — hidden by default -->
<template id="home-template">
<h1>Home</h1>
<p>Welcome to my app!</p>
</template>
<template id="about-template">
<h1>About</h1>
<p>This is the about page.</p>
</template>
<template id="404-template">
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
</template>
<!-- Scripts -->
<script src="dom-helpers.spa.min.js"></script>
<script src="app.js"></script>
</body>
</html>Why <template> elements?
<template> elements are inert — their content is not rendered by the browser, not executed, and not displayed. They're a perfect holding place for view content that the router will clone and inject on demand.
Step 2: Define and start the router
In your app.js:
Router
.define([
{
path: '/',
view: '#home-template',
title: 'Home'
},
{
path: '/about',
view: '#about-template',
title: 'About'
},
{
path: '*',
view: '#404-template',
title: 'Not Found'
}
])
.mount('#app')
.start({ mode: 'hash' });That's it. Your app now has:
- Working navigation between three pages
- Browser back/forward buttons
document.titleupdated on each navigation- Active link highlighting on the nav items
- A catch-all 404 page
Understanding each part
Router.define(routes)
Registers your route definitions. Each route is an object with:
| Property | Required | Description |
|---|---|---|
path | yes | The URL path. Use * for 404. |
view | yes | CSS selector for a <template>, or a factory function. |
title | no | Sets document.title on navigation. |
onEnter | no | Runs after the view mounts. |
onLeave | no | Runs before the view unmounts. |
Router.mount(selector)
Tells the router which element to use as the outlet — the container where views will be injected.
Router.mount('#app'); // CSS selector
Router.mount(document.body); // Or a direct element referenceRouter.start(options)
Attaches browser event listeners and immediately resolves the current URL.
Router.start({ mode: 'hash' }); // http://site.com/#/about
Router.start({ mode: 'history' }); // http://site.com/about (needs server config)Methods chain — you can write them fluently:
Router.define([...]).mount('#app').start({ mode: 'hash' });Step 3: Load order for modules
If you use the optional modules, the load order matters:
<!-- Core — always first -->
<script type="module">
import { load } from 'https://cdn.jsdelivr.net/npm/dom-helpers-js@2.10.0/dist/dom-helpers.loader.esm.min.js';
await load('spa');
</script>
<!-- Optional — any order after core -->
<script type="module">
import { load } from 'https://cdn.jsdelivr.net/npm/dom-helpers-js@2.10.0/dist/dom-helpers.loader.esm.min.js';
await load('spa');
</script>
<!-- Your app code — always last -->
<script src="app.js"></script>Each module checks for Router and logs a clear error if the core wasn't loaded first.
Adding a transition
Add the view module and call setTransition before start:
// After loading 02_dh-router-view.js
Router
.define([...])
.mount('#app')
.setTransition('fade') // ← one line adds a fade animation
.start({ mode: 'hash' });Built-in presets: 'fade', 'slide-left', 'slide-right', 'scale'.
Adding auth protection
Add the guards module and call requireAuth before start:
// After loading 04_dh-router-guards.js
Router.requireAuth(
() => !!localStorage.getItem('token'), // auth check
'/login' // redirect if not authed
);
Router
.define([
{ path: '/login', view: '#login-template' },
{ path: '/dashboard', view: '#dashboard-template' },
])
.mount('#app')
.start({ mode: 'hash' });What happens on first load?
When Router.start() is called:
1. Browser event listeners are attached (hashchange or popstate)
↓
2. Router reads the current URL
↓
3. Router matches it against your defined routes
↓
4. The matching view is cloned from its <template> and injected into the outlet
↓
5. The onEnter hook runs (if defined)
↓
6. document.title is updated (if defined)
↓
7. Active classes are applied to [data-route] linksThis happens in milliseconds — the user sees the correct page immediately.
Common beginner mistakes
❌ Calling start() before define()
// WRONG — no routes are registered yet
Router.mount('#app').start();
Router.define([...]);// RIGHT — define routes first
Router.define([...]).mount('#app').start();❌ Missing the outlet element
Router.mount('#app'); // Fine<!-- WRONG — no element with id="app" exists -->
<div id="main"></div><!-- RIGHT — selector matches an element -->
<div id="app"></div>❌ Referencing a template that doesn't exist
{ path: '/', view: '#home-template' }<!-- WRONG — id doesn't match -->
<template id="home"></template><!-- RIGHT — id matches exactly -->
<template id="home-template"></template>❌ Forgetting the catch-all route
Without a path: '*' route, navigating to an unknown URL triggers a notfound event but shows nothing.
// Always add a catch-all as the last route
{ path: '*', view: '#404-template' }The minimal working setup
<div id="app"></div>
<template id="home"><h1>Home</h1></template>
<template id="404"><h1>Not Found</h1></template>
<script src="dom-helpers.spa.min.js"></script>
<script>
Router.define([
{ path: '/', view: '#home' },
{ path: '*', view: '#404' }
]).mount('#app').start();
</script>This is the smallest possible working router. Everything else is optional.
What's next?
Now that you have a working router, let's explore:
- Defining routes with named parameters and wildcards
- Running code when a route is entered or left
- Programmatic navigation with
Router.go() - View transitions and animations