SPAs from Scratch
I recently took a deep dive into vanilla JavaScript through Frontend Masters, and Max showed that itâs possible to build full-featured Single Page Applications without reaching for React, Vue, or any of the usual suspects⌠AND I LOVED IT âŁď¸
No massive node_modules folder. No build configuration. No framework fatigue. Just JavaScript. Understanding how SPAs work under the hood makes you a better developer, regardless of which framework you eventually use. Youâll understand what your tools are ACTUALLY doing behind the scenes. Youâll make better architectural decisions, and youâll be empowered to solve issues that would otherwise seem like dark magic.
Plus, vanilla javascript SPAs are fast, lightweight, and sometimes - they are all you need! Need more convincing??? Check out my love letter to vanilla JavaScript. đ
SPA Fundamentals
The core concept behind Single Page Applications is beautifully simple: instead of requesting entirely new HTML documents from the server, you swap out chunks of your existing page while staying on the same HTML document. The page never truly reloads, it just shape-shifts.
Hereâs what navigation looks like in a SPA:
- The user clicks a link. They expect to go somewhere new.
- You intercept that click. Your JavaScript catches the event before the browser can do its default ânavigate to a new pageâ thing.
- You fetch new content. Maybe you call an API. Maybe you pull from DOM templates you already loaded. Maybe you generate content on the fly. The point isâyou get the data you need without a full page refresh.
- You manipulate the DOM. Out with the old content, in with the new. Youâre swapping sections of the page, updating what the user sees.
- You update the URL. Even though youâre not actually navigating, you change the browserâs address bar to reflect where the user âisâ in your app. Bookmarks still work. The back button still makes sense. Users remain blissfully unaware but in control.
The result? Your application state persists in memory. Animations stay smooth. Users feel like theyâre using a native app, not clicking through a website from 2008.
Faking Navigation (The History API)
Okay, âfakingâ sounds sketchy, but thatâs essentially what weâre doing. We need to make the URL bar change without actually navigating anywhere. Enter - The History API. đŞ
// Push a new URL onto the history stack
// The second argument (state) can store data; third is the URL
history.pushState({ page: "about" }, "", "/about");
// Listen for when users hit back/forward buttons
window.addEventListener("popstate", (event) => {
// event.state contains whatever you stored
console.log("User navigated! State:", event.state);
// Now update your UI to match the URL
});
Thatâs it. Thatâs the magic spell. pushState() changes the URL without reloading the page. The popstate event fires when users click back or forward. Combine these two, and youâve got yourself an event-driven SPA router.
Is it as fully-featured as React Router? No. Does it make you feel like a wizard? Absolutely. đ§ââď¸
Web Components
If youâve bought into a web framework, you know about web components. If you donât - sorry, I canât help you⌠youâre living under a rock.

Okay - fine, Iâll help⌠Web Components are modular building blocks that encapsulate functionality into reusable user interface elements in web development.
Hereâs the problem⌠the web components youâre probably used to only work in your framework. And hereâs the kicker⌠itâs not supposed to be that way! Web components are just custom HTML tags. Not JSX components. Not Vue components. Actual, honest-to-goodness HTML elements that work everywhere.
Want a <user-profile> tag? Make one. Want a <fancy-button> that glows when you hover? Go for it. The fun part? Once you define it, you can use it anywhere. No build step. No framework lock-in. Just HTML thatâs smarter than average.
Web Standards
Letâs get the technical bits out of the way:
Components are actually built on a set of web standards:
- Custom Elements: Define your own HTML tags (like
<cool-widget>) - HTML Templates: Reusable markup structures that donât render until you tell them to
- Shadow DOM: Encapsulation that keeps your componentâs styles and scripts from leaking everywhere
- Declarative Shadow DOM: The new kid on the blockâserver-renderable shadow DOM (fancy!)
Theyâre browser-native. Every modern browser supports them. No polyfills required (okay, maybe a small one for older browsers, but we donât talk about IE anymore).
Theyâre framework-agnostic. Use them with React. Use them with Vue. Use them with vanilla JavaScript. Use them with jQuery if youâre feeling nostalgic or masochistic.
You have freedom. Unlike framework components that dictate how you should structure things, web components let you decide. Want to use classes? Cool. Prefer functions that return element instances? Knock yourself out.
Custom Elements
Extending HTML
Because typing <div class="user-card"> a hundred times in your markup is for people who enjoy suffering.
Custom elements let you extend HTML itself. Youâre not fighting the platformâyouâre becoming one with it. Youâre the Avatar of the DOM.
Plus, thereâs something deeply satisfying about writing <my-component> and watching the browser just⌠handle it. No JSX transformation. No template compilation. Just pure, unadulterated HTML.
Custom Element Anatomy
Hereâs the basic anatomy of a custom element:
class MyElement extends HTMLElement {
constructor() {
// Set up initial state, event listeners, etc.
super(); // Always call super first (JavaScript will yell at you if you don't)
// Initialize your component
this.count = 0;
}
connectedCallback() {
// The element is added to the document
// This is like componentDidMount in React
console.log("I have been summoned!");
this.render();
}
disconnectedCallback() {
// The element is removed from the document
// Clean up! Remove event listeners! Stop those intervals!
console.log("I go now to that great <div> in the sky...");
}
adoptedCallback() {
// The element has been moved to a new document
// This rarely happens but hey, good to know about
console.log("I have been adopted by a new document family");
}
attributeChangedCallback(name, oldValue, newValue) {
// An observed attribute changed
// Only fires for attributes you specifically watch
console.log(`My ${name} changed from ${oldValue} to ${newValue}`);
}
// You need this to tell the browser which attributes to watch
static get observedAttributes() {
return ["count", "color"];
}
}
// Register your element with the browser
customElements.define("my-element", MyElement);
Now you can use <my-element>{/* ... */}</my-element> in your HTML like itâs been there all along. The browser doesnât bat an eye. It just works.
Want to pass data? Use attributes:
<my-element count="5" color="blue"></my-element>
The attributeChangedCallback fires, you update your internal state, and boomâreactive UI without a framework in sight. Learn more about the other lifecycle methods here.
Template Elements
Write the HTML Once
Remember when you had to create DOM elements in JavaScript using document.createElement() like some kind of savage?
const div = document.createElement("div");
div.className = "card";
const h2 = document.createElement("h2");
h2.textContent = "Title";
div.appendChild(h2);
// ... ad nauseam
Templates are the browser saying, âHey, maybe just write HTML instead?â
A <template> is an HTML element that holds markup that wonât be rendered immediately. Itâs inert. Invisible. Waiting. Like a spare tire in your trunkâthere when you need it, invisible when you donât.
Templates in Action
<template id="template1">
<header>
<h1>This is a template</h1>
<p>This content is not rendered initially</p>
</header>
</template>
Look at it. Sitting there. Not rendering. Just vibing. á(âšâĄâšá)
Now when you need to use it:
const template = document.getElementById("template1");
const clone = template.content.cloneNode(true); // Clone it (true = deep clone)
document.body.appendChild(clone); // Now it renders!
You can clone it as many times as you want. Each clone is independent. Modify one, and the others stay pristine. Itâs like having a blueprintâyou can build multiple houses from the same plan.
This is especially powerful with custom elements. Define a template once, clone it in your componentâs constructor, and voilĂ âinstant markup without string concatenation hell.
Shadow DOM
Style Encapsulation
Picture this: You create a beautiful custom element with gorgeous styles. Youâre proud. You deploy it. Then you realize the pageâs global CSS is bleeding into your component, turning your elegant design into a Picasso paintingâbut not in a good way.
Or worseâyour componentâs styles are leaking out and making the rest of the page look like it got dressed in the dark.
Thatâs the problem Shadow DOM solves. Itâs style encapsulation for web components. đ
By default, nodes in your custom element are part of the regular DOM. That means:
- Page CSS affects your component
- Your componentâs CSS affects the page
- Specificity wars đŞ
!importantdeclarations- Tears đĽ˛
Shadow DOM creates a shadow boundary.

(Yes - somehow this blog post became Sponge Bob themed! đ§˝ Donât ask me how it happened. Call it the creative process đ )
Styles donât cross it. Itâs like putting your component in a protective bubble. CSS selectors from outside canât reach in. CSS rules from inside canât leak out.
Shadow DOM in Action
class IsolatedElement extends HTMLElement {
constructor() {
super();
// Create a shadow root
const shadow = this.attachShadow({ mode: "open" });
// Now add content to the shadow root instead of directly to 'this'
shadow.innerHTML = `
<style>
/* This CSS only affects things in the shadow DOM */
p { color: hotpink; }
</style>
<p>I'm hot pink, and the page's p styles can't touch this</p>
`;
// Nah, nah, nah nah! Nah nah! Nah nah - can't touch this!
}
}
customElements.define("isolated-element", IsolatedElement);
The page might have p { color: blue; } defined globally, but inside your shadow DOM? Hot pink all day, baybee! đ
When should you use it?
- Building reusable components that might be used across different projects
- Creating UI libraries where style isolation is critical
- Any time you want to prevent the chaos of global CSS from ruining your day
When should you skip it?
- You want your component to inherit page styles
- Youâre building something simple and isolation feels like overkill
- You enjoy living dangerously đ
Here are the completed âPutting it all togetherâ and improved âConclusionâ sections:
Putting it all together
Now for the fun partâcombining everything weâve learned into an actual working SPA. Letâs build a simple multi-page app with routing and reusable components.
The HTML foundation:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My Vanilla SPA</title>
</head>
<body>
<nav>
<a href="/" data-link>Home</a>
<a href="/about" data-link>About</a>
<a href="/contact" data-link>Contact</a>
</nav>
<main id="app"></main>
<template id="user-card-template">
<div class="card">
<h3></h3>
<p></p>
</div>
</template>
<script src="app.js"></script>
</body>
</html>
The router (app.js):
// Intercept all link clicks
document.addEventListener("click", (e) => {
if (e.target.matches("[data-link]")) {
e.preventDefault();
navigateTo(e.target.href);
}
});
// Handle back/forward buttons
window.addEventListener("popstate", () => {
router();
});
// Navigate to a new route
function navigateTo(url) {
history.pushState(null, null, url);
router();
}
// The actual router logic
function router() {
const path = window.location.pathname;
const app = document.getElementById("app");
if (path === "/") {
app.innerHTML = "<h1>Home</h1>";
renderUserCards();
} else if (path === "/about") {
app.innerHTML = "<h1>About</h1><p>Built with vanilla JS!</p>";
} else if (path === "/contact") {
app.innerHTML = "<user-contact></user-contact>";
} else {
app.innerHTML = "<h1>404 - Not Found</h1>";
}
}
// Render some user cards using our template
function renderUserCards() {
const users = [
{ name: "Jose", bio: "Loves vanilla JS" },
{ name: "Cami", bio: "Shadow DOM enthusiast" },
];
const template = document.getElementById("user-card-template");
const app = document.getElementById("app");
users.forEach((user) => {
const clone = template.content.cloneNode(true);
clone.querySelector("h3").textContent = user.name;
clone.querySelector("p").textContent = user.bio;
app.appendChild(clone);
});
}
// Initialize on page load
router();
A custom web component (add to app.js):
class UserContact extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.contact-form {
padding: 20px;
border: 2px solid #333;
border-radius: 8px;
}
input, textarea {
display: block;
width: 100%;
margin: 10px 0;
padding: 8px;
}
button {
background: hotpink;
color: white;
border: none;
padding: 10px 20px;
cursor: pointer;
}
</style>
<div class="contact-form">
<h2>Get in Touch</h2>
<input type="text" placeholder="Your name" />
<input type="email" placeholder="Your email" />
<textarea placeholder="Your message"></textarea>
<button>Send</button>
</div>
`;
}
}
customElements.define("user-contact", UserContact);
Thatâs it! Youâve got:
- Client-side routing that updates the URL without page refreshes
- Template reuse for rendering lists of data
- Encapsulated web components with scoped styles
- No build tools, no dependencies, no framework
The beauty? You can deploy this as static files anywhere. No server-side rendering required (though you can add it). No compilation step. Just open index.html in a browser and watch it work.
Conclusion
Congrats - You finally understand whatâs happening when you use a framework. That âmagicâ Router component? Just the History API with some sugar. Those reactive components? Custom elements with a bit of state management. The Shadow DOM? Itâs been there all along, waiting for you to use it.
Hereâs what youâve learned:
- How to fake navigation with the History API
- How to create truly reusable web components that work anywhere
- How templates save you from
document.createElement()hell - How the Shadow DOM keeps your styles from becoming a war zone
More importantly, youâve learned how all of your favorite frameworks look like under the hood. And even when you do reach for React or Vue, youâll do so with intentionâknowing exactly what problems theyâre solving and what trade-offs youâre making. So go forth and build. Create some custom elements. And when someone asks, âWait, you built this without a framework?â just smile and say, âThe browser is the framework.â
Want to dive deeper? Take Maxâs course on Frontend Masters. Your future self (and your bundle size) will thank you. đŞ
Want more? Check out my love letter to vanilla JavaScript.