DEV Community

Tonis Anton
Tonis Anton

Posted on

JavaScript router in 60 lines

I was building a web application for myself and as NPM packages and JS frameworks are getting bigger and more complicated, I decided not to install some JS framework, and build the app from scratch this time.

Creating a new web-app requires Router to handle the page changes, and this is my attempt on creating one.

So what does router really do for the web application.

  • The app should be able to read what URL is open and show the required content, so for example, I open a page www.mybook.com/user/1, the page should render the user 1, information.

  • The page should listen to URL changes, so when I click a button or an image, that redirects the user to www.mybook.com/post/my-latest-news the page will not refresh, but instead removes the old content, and renders the new required content. This way of rendering content is usually called single page application or SPA.

  • The page should have URL history memory, so when I press back or forward buttons in the browser, the application should know what pages to show.

  • I would like the router to have a possibility to define routes and fire some action, when the user lands on that route.

For example

router.on("/post/my-latest-news", (params) => {
  // In here, I remove old content and render new one 
})
Enter fullscreen mode Exit fullscreen mode
  • I would also like the router to accept parameters in the URL.

For example, "/post/:id" would give me the id value as a parameter when deciding which post to show.

That's the basic of it, I think.

For listening to listening for route change, I will use the popstate listener API.

And for URL history, I am going to use browser History API


JavaScript Implementation

You can find the code for this router on Github

class Router {
    constructor() {
        this.routes = new Map();
        this.current = [];

        // Listen to the route changes, and fire routeUpdate when route change happens.
        window.onpopstate = this.routeUpdate.bind(this);
    }

    // Returns the path in an array, for example URL "/blog/post/1" , will be returned as ["blog", "post", "1"]
    get path() {
        return window.location.pathname.split('/').filter((x) => x != '');
    }

    // Returns the pages query parameters as an object, for example "/post/?id=2" will return { id:2 } 
    get query() {
        return Object.fromEntries(new URLSearchParams(window.location.search));
    }

    routeUpdate() {
        // Get path as an array and query parameters as an object
        const path = this.path;
        const query = this.query;

        // When URL has no path, fire the action under "/" listener and return 
        if (path.length == 0) {
            this.routes.get('/')(path);
            return;
        }

        // When same route is already active, don't render it again, may cause harmful loops.
        if (this.current.join() === path.join()) return;

        // Set active value of current page
        this.current = path;

        // Here I save the parameters of the URL, for example "/post/:page", will save value of page
        let parameters = {};

        // Loop though the saved route callbacks, and find the correct action for currect URL change
        for (let [route, callback] of this.routes) {

            // Split the route action name into array
            const routes = route.split('/').filter((x) => x != '');
            const matches = routes
                .map((url, index) => {
                    // When the route accepts value as wildcard accept any value
                    if (url == '*') return true;

                    // Route has a parameter value, because it uses : lets get that value from the URL
                    if (url.includes(':')) {
                        parameters[url.split(':')[1]] = path[index];
                        return true;
                    }
                    // The new URL matches the saved route callback url, return true, meaning the action should be activated.
                    if (url == path[index]) return true;
                    return false;
                })
                .filter((x) => x);

            // When the router has found that current URL, is matching the saved route name, fire the callback action with parameters included 
            if (matches.length == routes.length && routes.length > 0) {
                callback({ path, parameters, query });
            }
        }
    }

    // Listen for route changes, required route name and the callback function, when route matches.
    on(route, callback) {
        this.routes.set(route, callback);
    }

    // Fire this function when you want to change page, for example router.change("/user/1")
    // It will also save the route change to history api.
    change(route) {
        window.history.pushState({ action: 'changeRoute' }, null, route);
        window.dispatchEvent(new Event('popstate'));
    }
}

export default new Router();

Enter fullscreen mode Exit fullscreen mode

Using the router

PS!

You should also, add <base href="/"> in the header of your HTML file, so the front-end router, will always start the URL path from the start, and will not keep appending to the URL.


First, we import the Router

I am going to use ES6 native modules import, it's very easy and is supported by most browsers already.

import Router from '/libraries/router.js';
Enter fullscreen mode Exit fullscreen mode

You can export router class from the file as new directly, or you could just do something like this

window.router = new Router()
Enter fullscreen mode Exit fullscreen mode

PS!

My personal preference is to create the page as webcomponent or lit.js and then just swap the components when route is active.



Router.on('/home', (event) => {
    // Replace and render page content here
});


Router.on('/post/:id', (event) => {
    // Replace and render page content here
    // You can get parameter with, event.parameters.id
});
Enter fullscreen mode Exit fullscreen mode

Change routes

To change routes, you should use code below, because it will also store the URL change in browser history this way.

Router.change("/account")
Enter fullscreen mode Exit fullscreen mode

Backend setup

When creating the SPA app on web, you should be aware of an error what might happen.

When trying to load the page for a URL, for example www.mybook.com/user/1, the backend usually sends 404 error, page not found.

That happens, because backend has not defined a route for /user/1, the route finding for it, should happen on front-end side.

For fixing that, I redirect the 404 route on backend to index.html file or whatever one you are using.

So instead of backend sending route not found, it will send the SPA app main file, and then the SPA app router will render the correct page, because it has the information about the routes.


Tools to use for back-end proxy

For debugging locally, I am using Node.js and http-server

This console command, will run the http-server on current folder and will redirect all failed requests to main index.html and then JS router will take over.

http-server -p 8080 . --proxy http://localhost:8080?

For production, I am using Caddy as my backend proxy.
So here's a code-example how I send all 404 request to index.html in Caddy.

The try_files part, is where the failed routes are redirected.

https://www.mybook.com {
    root * /srv/www/mybook
    try_files {path} /index.html    
    encode zstd gzip
    file_server
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)