Routing

Introduction

When working with web applications, the ability to navigate between different pages is something we take for granted. Using HTML, we can setup anchor links and form actions that redirect the user to a different page. However, this is not the limit of what we can do in the browser when it comes to navigation.

In this lesson we will cover approaches to handling navigation in a JavaScript application by creating controllers that are responsible for handling the logic of a specific page. We will also explore how to use the window.location object to navigate to a different page.

Why Routing?

When building a small application, routing is not a considerable challenge. Each HTML file can load it’s own JavaScript file to control what happens on that page. However, as the application grows, this becomes more and more inconvenient. Instead of many unique files that are individually attached to pages, it is much easier to have a single entry point for the application that can handle all of the routing logic.

When we navigate around a web application, the URL changes to reflect the current page. We can track this URL and use it to determine what page we are on. This is the basis of routing in a JavaScript application.

Creating a Router

Let’s take a look at a basic router example, using else if to determine what page we are on and what should happen next:

javascript
	// js/router.js
	export function router() {
	  const path = window.location.pathname;
	
	  if (path === '/') {
	    // Home page
	  } else if (path === '/about') {
	    // About page
	  } else if (path === '/contact') {
	    // Contact page
	  } else {
	    // 404 page
	  }
	}

This structure is quite straightforward, the function will read the current pathname and match this to one of the blocks. If it cannot find a match, it will default to the 404 page.

We can use this router to setup our application by calling it in our entry point file that is attached to each HTML page:

javascript
	// js/index.js
	import { router } from './router.js';
	
	router();

Now we can update this example with some more functionality:

javascript
	import { setupCarousel } from './carousel.js';
	import { setupTimeline } from './timeline.js';
	import { setupForm } from './form.js';
	import { setup404 } from './404.js';
	
	export function router() {
	  const path = window.location.pathname;
	
	  if (path === '/') {
	    // Home page
	    setupCarousel();
	  } else if (path === '/about') {
	    // About page
	    setupTimeline();
	  } else if (path === '/contact') {
	    // Contact page
	    setupForm();
	  } else {
	    // 404 page
	    setup404();
	  }
	}

Here we have added some example functions to control behaviour on each page. However, this approach is not free of problems.

Index Files

As we learned in HTML & CSS, the browser will automatically look for an index.html file in a directory if no file is specified. For example, if we navigate to https://www.example.com/about, the browser will look for https://www.example.com/about/index.html. Since both /about and /about/index.html will return the same page, we need to account for this in our router.

javascript
	export function router() {
	  const path = window.location.pathname;
	
	  if (path === '/' || path === '/index.html') {
	    // Home page
	    setupCarousel();
	  } else if (path === '/about' || path === '/about/index.html') {
	    // About page
	    setupTimeline();
	  } else if (path === '/contact' || path === '/contact/index.html') {
	    // Contact page
	    setupForm();
	  } else {
	    // 404 page
	    setup404();
	  }
	}

By using an OR expression, we can account for both cases. However, this is not a very elegant solution and will start to become unmanageable as the application grows. In order to account for both cases, we can use a switch statement instead:

javascript
	export function router() {
	  const path = window.location.pathname;
	
	  switch (path) {
	    case '/':
	    case '/index.html':
	      // Home page
	      setupCarousel();
	      break;
	    case '/about':
	    case '/about/index.html':
	      // About page
	      setupTimeline();
	      break;
	    case '/contact':
	    case '/contact/index.html':
	      // Contact page
	      setupForm();
	      break;
	    default:
	      // 404 page
	      setup404();
	      break;
	  }
	}

Now we have a clean looking solution that handles both cases properly, but there are additional improvements that we can make. For example, we may need to access additional information from the URL such as a query parameter or a hash. We can do this by using the URL object. Let’s add a product path to our router:

javascript
	export function router() {
	  const url = new URL(window.location.href);
	  const path = url.pathname;
	  const query = Object.fromEntries(url.searchParams.entries());
	
	  switch (path) {
	    case '/':
	    case '/index.html':
	      // Home page
	      setupCarousel();
	      break;
	    case '/about':
	    case '/about/index.html':
	      // About page
	      setupTimeline();
	      break;
	    case '/contact':
	    case '/contact/index.html':
	      // Contact page
	      setupForm();
	      break;
	
	    case '/product':
	    case '/product/index.html':
	      // Product page
	      setupProduct(query.id);
	      break;
	
	    default:
	      // 404 page
	      setup404();
	      break;
	  }
	}

In the example above, we have added a new case for the product page. We can access the query parameters using url.searchParams and convert them to an object using Object.fromEntries.

Async Controllers

So far, we have used synchronous functions to setup each page, but in practice this will not always be the case. It is common to use async methods such as fetch to setup a page using data from an API. Let’s look at adapting our router to handle this:

javascript
	export async function router() {
	  const url = new URL(window.location.href);
	  const path = url.pathname;
	  const query = Object.fromEntries(url.searchParams.entries());
	
	  switch (path) {
	    case '/':
	    case '/index.html':
	      // Home page
	      await setupCarousel();
	      break;
	    case '/about':
	    case '/about/index.html':
	      // About page
	      await setupTimeline();
	      break;
	    case '/contact':
	    case '/contact/index.html':
	      // Contact page
	      await setupForm();
	      break;
	
	    case '/product':
	    case '/product/index.html':
	      // Product page
	      await setupProduct(query.id);
	      break;
	
	    default:
	      // 404 page
	      await setup404();
	      break;
	  }
	}

This allows us to use async functions to setup each page, and also means that we can update the entry point file to use async/await:

javascript
	// js/index.js
	import { router } from './router.js';
	
	async function app() {
	  await router();
	  // Other setup code
	}
	
	app();

This app function effectively controls the setup of the entire application. We can add additional setup code to this function, such as setting up a navigation menu, footer, user profile information, etc. Because the router function is async, we can use await to ensure that the setup code is run in the correct order.

javascript
	// js/index.js
	import { router } from './router.js';
	import { setupUI } from './ui.js';
	import { setListeners } from './listeners.js';
	
	async function app() {
	  await router();
	  setupUI();
	  setListeners();
	}
	
	app();

Programmatic Navigation

Although the router example handles the behaviour of the site as the user navigates around using links, it does not allow us to navigate programmatically. For example, we may want to redirect the user to a different page after they have submitted a form. We can do this using the window.location object.

There are a few ways to achieve this, each with their own pros and cons. Let’s look at the most common approaches:

window.location.href

The window.location.href property contains the entire URL of the current page. We can use this property to redirect the user to a different page by setting it’s value to a new URL.

javascript
	// Redirects the user to the Noroff website
	window.location.href = 'https://www.noroff.no';

This approach is simple and effective, but it does have some drawbacks. For example, if we use this method to redirect the user, they will not be able to use the back button to return to the previous page. This is because the browser will not store the previous page in the history.

window.location.replace()

The window.location.replace() method is similar to window.location.href, but it will replace the current page in the browser history instead of adding a new entry. This means that the user will not be able to use the back button to return to the previous page.

javascript
	// Replaces the current page with the Noroff website
	window.location.replace('https://www.noroff.no');

This is useful if we want to redirect the user to a different page, but we do not want them to be able to return to the previous page.

window.location.assign()

The window.location.assign() method is similar to window.location.href, but it will add a new entry to the browser history instead of replacing the current page. This means that the user will be able to use the back button to return to the previous page.

javascript
	// Redirects the user to the Noroff website
	window.location.assign('https://www.noroff.no');

window.location.reload()

The window.location.reload() method will reload the current page. This is useful if we want to refresh the page after a user has submitted a form.

javascript
	// Reloads the current page
	window.location.reload();

Creating a Redirect Function

We can create a simple function to handle redirects in our application. This function will take a URL as an argument and use window.location.assign() to redirect the user.

javascript
	// js/redirect.js
	export function redirect(path) {
	  try {
	    const url = new URL(path);
	    location.assign(url.href);
	  } catch {
	    console.error("Invalid URL: Could not redirect to path:", path);
	  }
	}

Let’s take a look at this in action:

javascript
	// js/form.js
	
	import { redirect } from './redirect.js';
	
	export function setupForm() {
	  const form = document.forms.contact;
	
	  form.addEventListener('submit', async (event) => {
	    event.preventDefault();
	
	    const formData = new FormData(form);
	    const data = Object.fromEntries(formData.entries());
	
	    await sendForm(data);
	    redirect('/thank-you');
	  });
	}

Hash Routing

In the examples above, we have looked at routing using the pathname value of the URL, i.e. the name of the HTML file that is currently being viewed. We can describe this setup as a Multi-Page Application (MPA), where each page is a separate HTML file. However, we can also use routing to create a Single-Page Application (SPA) where the entire application is contained within a single HTML file.

The advantage of an SPA is that we do not need to copy and paste the same HTML code into multiple files. Instead, we can use JavaScript to dynamically update the content of the page. This is useful for applications that have a lot of shared content between pages, such as a navigation menu or footer.

In order to create an SPA, we need to use a different approach to routing. Instead of using the pathname value, we can use the hash value. The hash is the part of the URL that comes after the # symbol. For example, in the URL https://www.example.com/#/about, the hash is #/about.

If we strip the # symbol from this value, we are left with /about which we can use to replace the pathname /about/index.html. This allows us to create an SPA that uses a single HTML file.

Let’s take a look at an example:

javascript
	// js/router.js
	
	export function router() {
	  const url = new URL(window.location.href);
	  const hash = url.hash.slice(1);
	
	  switch (hash) {
	    case '':
	    case 'index.html':
	      // Home page
	      setupCarousel();
	      break;
	    case 'about':
	      // About page
	      setupTimeline();
	      break;
	    case 'contact':
	      // Contact page
	      setupForm();
	      break;
	
	    case 'product':
	      // Product page
	      setupProduct(query.id);
	      break;
	
	    default:
	      // 404 page
	      setup404();
	      break;
	  }
	}

By changing the router to use the hash value, we can now use the same HTML file for each page. However, we need to update the links in our navigation menu to use the hash value instead of the pathname:

html
	<a href="#/about">About</a>
	<a href="#/contact">Contact</a>

Hash Routing with Parameters

Just like with pathname routing, we can use query parameters in our hash routing. For example, we can use the URL https://www.example.com/#/product?id=123 to load the product page with the ID of 123.

javascript
	// js/router.js
	
	export function router() {
	  const url = new URL(window.location.href);
	  const hash = url.hash.slice(1);
	  const query = Object.fromEntries(url.searchParams.entries());
	
	  switch (hash) {
	    case '':
	    case 'index.html':
	      // Home page
	      setupCarousel();
	      break;
	    case 'about':
	      // About page
	      setupTimeline();
	      break;
	    case 'contact':
	      // Contact page
	      setupForm();
	      break;
	
	    case 'product':
	      // Product page
	      setupProduct(query.id);
	      break;
	
	    default:
	      // 404 page
	      setup404();
	      break;
	  }
	}

Considerations

Although hash routing is a useful technique, it does have some drawbacks. Because a single HTML file is being dynamically updated instead of loading a new file, listeners and loops may continue to run in the background when they are no longer needed. This can cause memory leaks and performance issues.

In general, an SPA approach will involve more JavaScript code than an MPA approach. This can make the application more difficult to maintain and debug.

Ultimately, we use JavaScript frameworks to handle SPA behaviour in a consistent, safe and standardised way. It is a useful exercise to understand how routing works in vanilla JavaScript, and greatly improves our understanding of how frameworks work under the hood.

Pattern Matching

While the switch based router will work well for the majority of applications, it does have it’s own limitations. A switch simply compares one value with another, and if they are a perfect match it will execute the code block. However, this is not always the behaviour that we want. For example, we may want to match a value that starts with a certain string, or contains a certain string.

Let’s take a look at a more advanced form of routing that uses regex to match the URL:

javascript
	// js/router.js
	import { setupCarousel } from './carousel.js';
	import { setupTimeline } from './timeline.js';
	import { setupForm } from './form.js';
	import { setupProduct } from './product.js';
	import { setup404 } from './404.js';
	
	export async function router() {
	  const url = new URL(window.location.href);
	  const hash = url.hash.slice(1);
	
	  // Path regex handles / and /index.html cases
	  const routes = [
	    { path: /^\/(index.html)?$/, controller: setupCarousel },
	    { path: /^\/about(\/index.html)?$/, controller: setupTimeline },
	    { path: /^\/contact(\/index.html)?$/, controller: setupForm },
	    { path: /^\/product(\/index.html)?$/, controller: setupProduct },
	    { path: /.*/, controller: setup404 },
	  ];
	
	  const route = routes.find((route) => route.path.test(hash));
	
	  await route.controller();
	}

In the example above, we have created an array of objects that contain a path and a controller. The path is a regex that will match the URL, and the controller is a function that will setup the page. We can then use the find method to find the first route that matches the URL.

This is useful in situations where routes may be dynamic, for example an e-commerce site where the product ID is part of the URL. We can use regex to match the URL and extract the product ID from the URL:

javascript
	// js/router.js
	import { setupCarousel } from './carousel.js';
	import { setupTimeline } from './timeline.js';
	import { setupForm } from './form.js';
	import { setupProduct } from './product.js';
	
	export async function router() {
	  const url = new URL(window.location.href);
	  const hash = url.hash.slice(1);
	
	  // Path regex handles / and /index.html cases
	  const routes = [
	    { path: /^\/(index.html)?$/, controller: setupCarousel },
	    { path: /^\/about(\/index.html)?$/, controller: setupTimeline },
	    { path: /^\/contact(\/index.html)?$/, controller: setupForm },
	    {
	      path: /^\/product\/(?<id>\d+)(\/index.html)?$/,
	      controller: () => setupProduct(id),
	    },
	  ];
	
	  const route = routes.find((route) => route.path.test(hash));
	
	  await route.controller();
	}

In the example above, we have added a named capture group to the regex that will match the product ID. We can then use this ID to setup the product page. This allows for a URL such as https://www.example.com/#/product/123 to be matched and the product ID to be extracted instead of https://www.example.com/product/?id=123. Its important to note that this will not work for a MPA approach, and can only be used in combination with a SPA approach.

Lesson Task

Create a simple application with 3 pages:

  • Home
  • Contact
  • 404

Using one of the approaches covered in this lesson, setup a router that will handle navigation between these pages. Use the alert method to display the name of each page to the user when they navigate to it.

Additional Goals

Use the router to set a contact form event listener on the contact page that prevents default and displays a success message to the user.