Building a real SPA in 200 lines of vanilla JS.
React, Vue, Svelte — all overkill for a portfolio, content site, or small product. The platform now ships everything you need: History API for routing, custom events for state propagation, Proxies for reactivity. Here's the complete recipe — every piece I used to build iamr.net itself, no build step, no NPM, no dependencies.
01The case against frameworks for small sites
React + Next.js for a 5-page portfolio means: 300 KB of JS shipped to the user, a build step that takes 30 seconds, a deployment pipeline, ongoing dependency upgrades, and CVE patches every few months. The framework solves problems you don't have.
For a content site or portfolio, your real requirements are:
- URL changes when you navigate (no full reload)
- Browser back/forward buttons work
- Shareable deep links
- Theme/preference state persists across pages
- Modal/drawer state managed cleanly
That's it. All five are solvable in 200 lines.
02Router — 30 lines
The History API gives you pushState, replaceState, and the popstate event. That's enough to build a router.
const routes = {
'/': () => renderHome(),
'/writing': () => renderWriting(),
'/writing/:slug': (params) => renderArticle(params.slug),
'/portfolio': () => renderPortfolio(),
};
function match(path) {
for (const pattern in routes) {
const regex = new RegExp('^' + pattern.replace(/:(\w+)/g, '(?<$1>[^/]+)') + '$');
const m = path.match(regex);
if (m) return { handler: routes[pattern], params: m.groups || {} };
}
return null;
}
function navigate(path) {
history.pushState(null, '', path);
render(path);
}
function render(path) {
const route = match(path);
if (route) route.handler(route.params);
else renderNotFound();
}
// Listen for back/forward
window.addEventListener('popstate', () => render(location.pathname));
// Intercept internal link clicks
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href^="/"]');
if (link && !link.target) {
e.preventDefault();
navigate(link.getAttribute('href'));
}
});
// Initial render
render(location.pathname);
That's it. URLs change. Back button works. Routes can have parameters. Internal links automatically use the router, external links work normally.
03State — 20 lines with Proxy
For shared state (theme, user preferences, modal open/closed), you don't need Redux. A Proxy wrapped around an object, dispatching an event on every mutation, gives you a reactive store.
function createStore(initial) {
const listeners = new Set();
const proxy = new Proxy(initial, {
set(target, key, value) {
const prev = target[key];
target[key] = value;
if (prev !== value) {
listeners.forEach(fn => fn(key, value, prev));
}
return true;
}
});
proxy.subscribe = (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
return proxy;
}
// Usage
const store = createStore({
theme: 'dark',
menuOpen: false,
user: null,
});
store.subscribe((key, value) => {
console.log(key, 'changed to', value);
if (key === 'theme') document.body.dataset.theme = value;
});
// Mutation triggers subscribers
store.theme = 'light'; // → logs "theme changed to light"
store.menuOpen = true; // → logs "menuOpen changed to true"
20 lines. You can subscribe to all changes, react to specific keys, and unsubscribe by calling the function returned from subscribe. That's the same shape as Zustand's API.
04Persistence — localStorage in 10 lines
Want the theme to survive a page reload? Hook the store to localStorage.
function persistStore(store, key) {
// Restore on load
try {
const saved = JSON.parse(localStorage.getItem(key) || '{}');
Object.assign(store, saved);
} catch {}
// Persist on every change
store.subscribe(() => {
const snapshot = { ...store };
delete snapshot.subscribe;
localStorage.setItem(key, JSON.stringify(snapshot));
});
}
persistStore(store, 'iamr-state');
05Rendering — template literals + targeted updates
You don't need a virtual DOM. For small surfaces, just rebuild the relevant container with template literals:
function renderArticle(slug) {
const main = document.querySelector('main');
const article = ARTICLES[slug];
if (!article) {
main.innerHTML = '<h1>Not found</h1>';
return;
}
main.innerHTML = `
<article class="post">
<a href="/writing" class="back">← back</a>
<h1>${escapeHtml(article.title)}</h1>
<time>${article.date}</time>
<div class="body">${article.body}</div>
</article>
`;
// Update page title
document.title = `${article.title} · iamr.net`;
// Scroll to top
window.scrollTo(0, 0);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
For most sites, innerHTML assignment is more than fast enough. Modern browsers parse it in microseconds. Only reach for virtual DOM diffing when you have lists with thousands of items that update partially — and at that scale, you probably already have other reasons to use a framework.
06When to NOT do this
Be honest about scale. The vanilla approach works for:
- Portfolios, content sites, blogs
- Marketing sites with a few interactive widgets
- Documentation sites
- Small dashboards (under 50 components)
- Single-purpose tools (calculators, generators, viewers)
It breaks down when:
- You have deeply nested reactive UI — 10+ components observing the same state, all needing partial re-renders. Virtual DOM diffing earns its weight here.
- You have a large team — frameworks impose structure that helps teams stay aligned. Vanilla code is too free.
- You need server-side rendering for SEO — vanilla SPA renders client-side only, so search engines see an empty shell unless you pre-render. (Solution: build static HTML pages for the public content; use the vanilla SPA only for authenticated/dynamic surfaces.)
- Your team is React-only — adopting vanilla means new patterns to learn, debug, and document. Trade-off worth it only when the wins are concrete.
∞The real point
This isn't about anti-framework purity. It's about using the right tool. The platform in 2026 ships everything that was originally a framework's reason to exist. fetch, Promise, async/await, Proxy, History API, Web Components, ES modules — all built-in.
For the kind of site that doesn't need 300 KB of framework, those built-ins are enough. Ship light, ship fast, own your code.