Building a Small Native SPA Library
How to build a lightweight single-page application library using vanilla JavaScript ES modules, hash routing, and AbortSignal cleanup.
I see building a small framework as a way to understand what existing tools handle. When building a client-side interface like a searchable book catalog, you quickly face three core browser problems: synchronizing the visible screen with URL changes, swapping DOM content without leftover elements, and detaching event listeners when a screen unmounts. To follow this tutorial, you only need familiarity with fundamental JavaScript and DOM APIs.
In this tutorial, you will build a minimal single-page application library in plain JavaScript with zero external dependencies. The project consists of three files:
index.html: The HTML shell that mounts the application.spa.js: A lightweight native router library that manages routes and screen lifecycles.app.js: The application code, defining a searchable book catalog and an About page.
The HTML shell
Start with a clean HTML document. Create a file named index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Book Catalog SPA</title>
</head>
<body>
<header>
<nav>
<a href="#/">Catalog</a>
<a href="#/about">About</a>
</nav>
</header>
<main id="app"></main>
<script type="module" src="./app.js"></script>
</body>
</html>
The document provides a basic navigation header with hash links (#/ and #/about), an empty <main id="app"> element to hold active views, and a module script tag.
Using JavaScript modules in the browser provides scoped files and native import and export statements without requiring a bundler. Hash-based navigation simplifies routing on static web servers: because the browser does not send the fragment identifier (the # portion) in HTTP requests, direct links like index.html#/about avoid server-side path routing, provided the static host is configured to serve index.html at the application base URL.
The routing library
Next, build the router in spa.js. The library needs to resolve the current URL hash, find the matching view, replace the container content, and provide each view with an AbortSignal for cleanup:
// spa.js
export function createRouter({ routes, container, fallback }) {
let currentController = null;
let isRunning = false;
function resolvePath() {
const hash = window.location.hash;
return hash.startsWith("#") ? hash.slice(1) || "/" : "/";
}
function render() {
if (!isRunning) return;
if (currentController) {
currentController.abort();
currentController = null;
}
const controller = new AbortController();
currentController = controller;
try {
const path = resolvePath();
const view = routes.get(path) ?? fallback;
const content = view({ signal: controller.signal });
container.replaceChildren(content);
const heading = container.querySelector("h1");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus();
}
} catch (error) {
controller.abort();
if (currentController === controller) {
currentController = null;
}
container.replaceChildren();
throw error;
}
}
function onHashChange() {
render();
}
function start() {
if (isRunning) return;
isRunning = true;
window.addEventListener("hashchange", onHashChange);
render();
}
function stop() {
if (!isRunning) return;
isRunning = false;
window.removeEventListener("hashchange", onHashChange);
if (currentController) {
currentController.abort();
currentController = null;
}
}
return { start, stop };
}
This router coordinates browser primitives through five focused mechanisms:
- Hash-based navigation: The library listens to the
hashchangeevent onwindow. When the user clicks a hash link or uses the browser Back and Forward buttons, the URL hash updates without triggering a full page reload. TheresolvePathhelper normalizes empty hashes, bare#symbols, or direct visits so that initial page loads without a hash mount the root catalog view ("/") automatically. - Route resolution with Map: The route table uses a JavaScript
Map. Unlike a plain object,Map.prototype.get()only checks its own entries. This prevents unintended matches on inherited prototype properties such asconstructoror__proto__, directing unmapped paths straight to the fallback view. - Atomic container replacement: The router calls
Element.replaceChildren()on the container element. This native DOM method empties previous children and mounts the new view node in a single call, avoiding raw HTML string concatenation and eliminating manual DOM tree teardown loops. - Accessible focus transitions: After a route mounts, moving keyboard focus to that view's heading in a deliberate accessible way (
tabindex="-1"on theh1, then.focus()) informs assistive technologies and keyboard users of the screen change. This is a practical navigation transition behavior, not a universal framework rule. - Teardown signaling and error cleanup: When switching routes, the router creates a fresh
AbortControllerand passes itssignalto the view. CallingcurrentController.abort()signals to the departing view that it should stop work and detach its event listeners. If a view throws during synchronous rendering, thecatchblock aborts the new controller, clears active-controller bookkeeping, empties the container to avoid displaying a broken partial view, and rethrows the error transparently. Thestop()method removes the global listener and aborts the active view, and calling it repeatedly is safe.
The application views
Now create app.js. This file defines a book catalog with live search, an About view, and a fallback view for unknown paths:
// app.js
import { createRouter } from "./spa.js";
const books = [
{ title: "Browser Notes" },
{ title: "Café JavaScript" },
{ title: "Routes & Views" },
];
function catalogView({ signal }) {
const section = document.createElement("section");
const heading = document.createElement("h1");
heading.textContent = "Book Catalog";
section.appendChild(heading);
const label = document.createElement("label");
label.htmlFor = "search-input";
label.textContent = "Search books";
section.appendChild(label);
const searchBox = document.createElement("input");
searchBox.id = "search-input";
searchBox.type = "search";
searchBox.placeholder = "Filter by title...";
section.appendChild(searchBox);
const list = document.createElement("ul");
section.appendChild(list);
function renderList(query = "") {
list.replaceChildren();
const clean = query.trim().toLowerCase();
const matches = books.filter((b) => b.title.toLowerCase().includes(clean));
if (matches.length === 0) {
const empty = document.createElement("li");
empty.textContent = "No books found.";
list.appendChild(empty);
return;
}
for (const book of matches) {
const item = document.createElement("li");
item.textContent = book.title;
list.appendChild(item);
}
}
renderList("");
searchBox.addEventListener(
"input",
(event) => renderList(event.target.value),
{ signal }
);
return section;
}
function aboutView() {
const section = document.createElement("section");
const heading = document.createElement("h1");
heading.textContent = "About This SPA";
const desc = document.createElement("p");
desc.textContent =
"A minimal single-page application built with plain JavaScript ES modules, hash routing, and AbortSignal cleanup.";
section.appendChild(heading);
section.appendChild(desc);
return section;
}
function notFoundView() {
const section = document.createElement("section");
const heading = document.createElement("h1");
heading.textContent = "Page Not Found";
const message = document.createElement("p");
message.textContent = "The requested page does not exist.";
const link = document.createElement("a");
link.href = "#/";
link.textContent = "Return to Catalog";
section.appendChild(heading);
section.appendChild(message);
section.appendChild(link);
return section;
}
const routes = new Map([
["/", catalogView],
["/about", aboutView],
]);
const router = createRouter({
routes,
container: document.getElementById("app"),
fallback: notFoundView,
});
router.start();
Notice how catalogView coordinates with the router contracts:
- Automatic listener cleanup: Passing
{ signal }intoaddEventListenertells the browser to automatically remove theinputlistener when the router aborts the active view signal. When the user navigates from the catalog to the About page, the search listener detaches without requiring manual bookkeeping or custom destructor functions. - Data text rendering: Each list item assigns book data using
Node.textContent. Because strings are set directly as text nodes rather than parsed HTML markup, the browser renders the exact string characters without evaluating them as markup. - Narrow substring filtering: The search filter trims surrounding whitespace with
trim(), converts both the query and the title to lowercase withtoLowerCase(), and checks for matches using.includes(). This matches exact character substrings, but does not perform accent normalization or canonical-equivalence matching. For instance, searching forcafématchesCafé JavaScript, whereas searching forcafewithout the acute accent does not.
Running the application
Because browser ES modules are subject to CORS restrictions on local file:// URLs, serve the directory over HTTP. Open your terminal in the project directory and start a local HTTP server:
python3 -m http.server 3000 --bind 127.0.0.1
This command binds the server to loopback (127.0.0.1), limiting access strictly to your local machine. Open http://127.0.0.1:3000/ in your browser.
You can click between the Catalog and About links, use the browser Back and Forward buttons, and type into the search box to filter the titles. If you change the URL hash to an unknown path like #/unknown or #__proto__, the router cleanly renders the not-found view.
One direct consequence of this architecture is that route changes remount the view from scratch. When you navigate from the catalog to the About page and back, the router creates a fresh view instance, which resets the search box and restores the full list. For an educational library, that boundary keeps DOM ownership straightforward: the router decides when a view mounts and unmounts, while each view owns its local controls and initial state.