You can build a single-page application with vanilla JavaScript by using the browser’s native APIs like the History API for routing, the Fetch API for data requests, and the DOM API for rendering. The core approach involves manually managing state, detecting URL changes, and updating the DOM in response to user interactions—no framework required. For example, you could create a simple note-taking app where clicking a navigation link triggers a popstate event listener that loads different content into a container div without a full page reload.
Building SPAs without a framework is entirely feasible if your application remains relatively simple. Many teams choose this approach to avoid the overhead of framework tooling, reduce bundle size, and maintain direct control over how the application behaves. However, the trade-off is that you’ll be writing more code to handle concerns that frameworks typically abstract away, such as component state management and efficient DOM updates.
Table of Contents
- What Does It Take to Build a Framework-Free SPA?
- Managing State Without a State Management Library
- Routing Without a Router Library
- Rendering and Updating the DOM Efficiently
- Handling Asynchronous Operations and Race Conditions
- Organizing Your Code for Maintainability
- When to Stick With Vanilla JavaScript and When to Reconsider
- Conclusion
What Does It Take to Build a Framework-Free SPA?
The foundation of a framework-free SPA rests on three browser APIs. The Fetch API lets you retrieve data asynchronously without page reloads. The History API (pushState and popState) allows you to manage browser history and respond to back-button clicks. The DOM API provides full control over rendering by adding, removing, and modifying HTML elements. These tools have been stable in all modern browsers for years, making them a reliable basis for SPA development.
To illustrate, consider a two-page app with a home view and an about view. You’d set up event listeners on navigation links that call history.pushState to update the URL, then trigger a function that clears the current content and renders the appropriate view. A popstate listener ensures the page re-renders correctly when users navigate backward. This manual approach is verbose but transparent—you see exactly what happens at each step, which can be valuable for understanding and debugging. The main limitation is that as your application grows beyond a few pages and screens, this manual approach becomes increasingly tedious. You’ll find yourself repeating patterns like “fetch data, wait for the response, update the DOM,” which frameworks bundle into reusable primitives.

Managing State Without a State Management Library
Without a framework, your application state typically lives in javascript objects or simple variables that you manipulate and reference throughout your code. A common pattern is to maintain a single global state object—sometimes called a “store”—that holds all the data your app needs. When state changes, you manually call functions to update the DOM to reflect those changes. For example, if you’re building a todo list app, you might create a state object with a todos array. When a user adds a todo, an event listener captures the input, your code updates the todos array in state, and then you re-render the entire list by generating fresh HTML and inserting it into the DOM.
This approach works reasonably well for small apps but becomes error-prone as complexity grows. It’s easy to accidentally update state without updating the view, or vice versa, leading to the view becoming out of sync with the underlying data. A significant drawback is the lack of automatic reactivity. Frameworks track state changes and update the DOM automatically, while in vanilla JavaScript you must explicitly call render functions. If you forget to call the render function after updating state, your users will see stale information. Larger SPAs often mitigate this by creating a minimal abstraction layer—something similar to a framework—but stopping short of adopting a full framework.
Routing Without a Router Library
Implementing routing without a dedicated library means writing your own URL change detection and view-loading logic. The basic pattern is to add a popstate event listener to detect when the user navigates backward or forward, and to attach click listeners to links that call history.pushState before loading the appropriate view. Here’s a practical example: when a user clicks a link with href=”/products”, you preventDefault to stop the default navigation, call history.pushState with the URL, then trigger a function that fetches products data and renders the products page. When the user clicks the back button, the popstate event fires, and you extract the URL from the event to determine which view to render.
This works but requires careful setup—you must ensure every link that should navigate does so through this listener, and you must handle edge cases like direct URL entry and page refresh. One warning: if you’re not careful with your routing logic, you can end up with memory leaks. For instance, if you attach event listeners to elements that you later remove from the DOM during a route change, those listeners may persist in memory. Frameworks handle cleanup automatically, but in vanilla JavaScript you must explicitly remove listeners before discarding elements.

Rendering and Updating the DOM Efficiently
The DOM is the slowest part of a web application, so even without a framework, you should be mindful of how you update it. A naive approach is to rebuild the entire view every time data changes, which works but can be slow if you have many elements. A more efficient approach is to write helper functions that update only the specific elements that changed, reducing the number of DOM operations. For instance, if your app displays a list of items and one item’s status changes, instead of rebuilding the entire list, you could find the specific list item in the DOM and update only that element.
This is where vanilla JavaScript differs from frameworks—frameworks typically handle this optimization through virtual diffing, while you must implement targeted updates manually. The benefit is smaller and more predictable updates; the downside is more code to write and more opportunities for bugs. You can also use the innerHTML property to insert HTML strings, but this approach carries a security risk if you include user-generated content without sanitization. Always escape user input, or use methods like textContent for plain text and createElement for structured content.
Handling Asynchronous Operations and Race Conditions
Without a framework managing async state, you must carefully coordinate multiple asynchronous operations—data fetches, timeouts, animations—to prevent race conditions and stale data. If a user navigates to a new page while the previous page’s data is still loading, your code must ignore the old response and only apply the new response. A common pitfall occurs when route changes trigger multiple data fetches. If fetch B completes before fetch A, but A was initiated first, you could accidentally render stale data from A after B.
To prevent this, you can store a request ID or timestamp and only apply responses that are newer than previous responses. Alternatively, you can maintain a flag indicating whether a view is still active and ignore responses for inactive views. Another consideration is error handling. When a fetch fails, you need code to display an error message and potentially retry. Frameworks often provide built-in patterns for this; in vanilla JavaScript, you must write it yourself, and the code can become unwieldy if not carefully structured.

Organizing Your Code for Maintainability
As a framework-free SPA grows, it becomes critical to organize your JavaScript files and functions logically. A common approach is to group code by feature or view—for example, a folder for the products feature containing functions to fetch products, render the products view, and handle product-related events. This mirrors the organization you’d find in framework-based projects.
You might also create utility modules for common tasks like DOM manipulation helpers, API request functions, or state management. For example, you could write a helper function that creates and appends elements, reducing boilerplate when rendering views. Another utility might wrap the Fetch API to handle headers, error codes, and JSON parsing consistently across your app. This modularization doesn’t require build tools—you can use ES6 import/export syntax with a bundler like Webpack or Parcel, or even rely on browser-native ES modules if your target browsers support them.
When to Stick With Vanilla JavaScript and When to Reconsider
A framework-free SPA is a sensible choice for small to medium applications with straightforward routing and state management needs. A company directory app, a simple portfolio site with a few interactive sections, or an internal tool used by a single team might be excellent candidates. The advantages are clear: fewer dependencies to maintain, a smaller bundle size, and the ability to understand every line of code.
However, if your SPA requires complex state management, frequent component reuse, a large team of developers, or advanced features like real-time collaboration or complex animations, the time you save by starting with vanilla JavaScript may be lost to bugs and maintenance overhead later. Many developers find that the investment in learning a lightweight framework like Vue or Svelte becomes worthwhile once an application passes a certain size threshold. The decision ultimately hinges on the scope of your project and the skills of your team.
Conclusion
Building a JavaScript SPA without a framework is straightforward in principle and practical in execution for projects of limited scope. You use the History API for routing, Fetch for data requests, and the DOM API for rendering, manually coordinating these pieces to create a responsive user experience.
The path is clear: wire up navigation listeners, manage a state object, fetch data as needed, and update the DOM accordingly. The real challenge emerges as your application scales—coordinating async operations, keeping the view synchronized with state, and organizing increasingly complex code all become harder without framework abstractions. Start with vanilla JavaScript if your application is small and your team prefers to avoid dependencies, but remain aware of the transition point where adopting a lightweight framework becomes a pragmatic investment in long-term maintainability.




