Migrating “any” Web app to SPA: The 5 step Guide

Abdulbasit Rubeya
5 min readSep 28, 2024

--

freepik: Bright neon colors shining on wild chameleon

Creating single-page apps is straightforward until you need to migrate an existing multi-page web app into one. The key challenge lies in handling navigation, dynamically updating content, and managing browser history without refreshing the entire page. This tutorial will guide you through the process, breaking down each part of the code and how it contributes to the SPA migration.

Introduction

In a traditional web application, each page reloads completely when the user navigates. In contrast, a single-page application (SPA) loads a single HTML document and dynamically updates specific parts of the page, providing faster interactions and smoother user experiences. We’ll take a simple step-by-step approach to convert an existing multi-page app into an SPA by dynamically fetching content and handling browser navigation seamlessly.

Before starting, I’d assume that your web application has a single, reusable base structure where only the content inside a specific element (like #content) changes on navigation and all or atleast most of the anchor links have a valid hypertext reference attribute values href.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Web App</title>

<!-- stylesheets -->
</head>
<body>
<header>
<nav>
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>

<main id="content">
<!-- This section will change dynamically -->
</main>

<footer>
<p>&copy; 2024 My Web App</p>
</footer>

<!-- scriptsheets -->
<script src="spa.js"></script>
</body>
</html>

The only section that will change during navigation is the "#content" area. Every other element outside the conten area (header, footer, etc.) will remain intact during page changes.

1. Intercept Link Clicks

Yep, that’s step one — The very goal is to intercept link clicks, prevent the default behavior (full page reload), and dynamically load content instead.

document.addEventListener('DOMContentLoaded', () => {

function bindLinks() {
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', function(event) {
event.preventDefault();
const url = this.getAttribute('href');
history.pushState(null, '', url); // Update URL without reload
loadPage(url); // Dynamically load the new content
});
});

}

Breaking it Down:

DOMContentLoaded:

  • This event ensures that the script only runs after the entire HTML content is fully loaded. It prevents errors that could occur if the DOM isn’t ready when the script executes.

bindLinks:

  • This function selects all anchor (<a>) tags on the page using document.querySelectorAll('a') and attaches a click event listener to each valid link.

Event Listener:

  • event.preventDefault() prevents the default action of the link, which would cause a full page reload.
  • history.pushState() changes the browser's URL without reloading the page.
  • loadPage(url) calls another function that fetches and updates the page content dynamically (explained in the next step).

2. Fetch Page and Parse It

Once a valid link is clicked, the next step is to fetch the corresponding page and update only the relevant content area (#content), leaving the rest of the page unchanged.

function loadPage(url) {
fetch(url)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('#content').innerHTML;
document.querySelector('#content').innerHTML = newContent;

const newTitle = doc.querySelector('title').innerText;
document.title = newTitle;

bindLinks(); // Re-bind new links
})
.catch(error => console.error('Failed to load page:', error));
}

We parse the page after fetching because in a traditional MPA, the markup called from the server is a full page markup, meaning that it contains the entirety of the original page, so we only need to load what’s needed.

Breaking it Down:

fetch(url):

  • Fetches the full HTML document from the server for the requested URL.
  • It returns a promise that resolves with the response, which is then converted into plain text using response.text().

DOMParser:

  • This object is used to convert the fetched HTML string into a fully parseable HTML document.
  • parser.parseFromString(html, 'text/html') parses the HTML string into an actual DOM structure.

Extracting the content:

  • doc.querySelector('#content').innerHTML extracts the #content from the fetched HTML, which contains the new content for the page.
  • This content is then injected into the current page’s #content element, replacing the old content with the newly fetched content.

Updating the title:

  • The page title is updated using the <title> tag from the fetched document: doc.querySelector('title').innerText.

Re-binding links:

  • Since the content has changed, new links may have been loaded into the #content section. Therefore, bindLinks() is called again to attach click event handlers to the newly added links.

3. Browser Navigation and History Management

One of the core functionalities of a SPA is ensuring the back and forward buttons work as expected. When the user navigates using these buttons, you need to load the appropriate content without refreshing the page.

window.addEventListener('popstate', () => {
loadPage(location.pathname);
});

Breaking it Down:

window.addEventListener('popstate'):

  • The popstate event is triggered when the user clicks the browser's back or forward button. This listener will intercept these actions.

loadPage(location.pathname):

  • When the event is triggered, the app reloads the correct content based on the current URL (location.pathname), ensuring that the user sees the right content as they navigate through their history.

4. Link Validation

Not all links should be intercepted. For example, hash links (#something) and JavaScript-based links i.e javascript:void or any other fragment-only URIs should be ignored.

function isValidLink(link) {
return link &&
!link.startsWith('#') &&
!link.startsWith('javascript') &&
!link.startsWith('http') && // Ignore external HTTP requests
!link.startsWith('//') && // Ignore protocol-relative URLs
!link.includes('target="_blank"'); // Ignore links with target="_blank"
}

Since we’ve added a validation check, we’ll also need to modify our anchor interceptor to work with the validation function which should be something like —

function bindLinks() {
document.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('href'); // get href value
if (isValidLink(href)) { // validate link
link.addEventListener('click', function(event) {
event.preventDefault();
const url = this.getAttribute('href');
history.pushState(null, '', url);
loadPage(url);
});
}
});
}

Initializing the SPA

Finally, you need to ensure that the initial set of links is correctly bound when the page first loads:

bindLinks();
  • Purpose: This is called when the page initially loads to attach the dynamic behavior to all existing links in the document.

Conclusion: Handling Edge Cases

Migrating to a Single Page Application (SPA) involves more than just updating content; it requires careful consideration of various factors to ensure a smooth user experience. Here are key edge cases to keep in mind:

  • Form Submissions: It’s essential to intercept form submissions to prevent page reloads. By handling these through AJAX, you can maintain the dynamic nature of your SPA while providing a seamless experience for users.
  • SEO Considerations: SPAs load content dynamically, which can pose challenges for search engine crawlers. To enhance your app’s visibility, consider implementing server-side rendering (SSR).

In summary, migrating a web application to a SPA requires attention to content loading, browser history, and dynamic updates. By addressing these edge cases, you can ensure a smoother transition for your users. While the process can be straightforward when broken down into manageable steps, being mindful of these considerations will enhance your app’s performance and usability.

As you refine and expand your SPA, explore advanced techniques such as lazy loading, caching, and state management to further improve user experience. Happy coding!

Links

author: https://github.com/ibnsultan
repository: https://github.com/ibnsultan/spa-id

--

--

Abdulbasit Rubeya
Abdulbasit Rubeya

No responses yet