Blog Resume

SPAs from Scratch

WordCount:
2133
Reading Time:
11 min read

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:

  1. The user clicks a link. They expect to go somewhere new.
  2. You intercept that click. Your JavaScript catches the event before the browser can do its default “navigate to a new page” thing.
  3. 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.
  4. 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.
  5. 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.

Patrick Star from Spongebob Squarepants going back to his home 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 🪖
  • !important declarations
  • Tears 🥲

Shadow DOM creates a shadow boundary.

SpongeBob creating a rainbow with the subtitle "Boundaries"

(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.