Integrating Web Apps with Static Sites

Last year, I switched my blog from using a content management application to using a static site generator Hugo to serve a static web site. In terms of serving content on my blog, the results have been very good; however, the lack of interactivity has left me wanting funtionality on the site that the static site byitself lacks. It is possible to add third party apps for certain functionality like post commenting, but I have never really been a fan of third party services on my site, so I didn’t add those things.

After living with that lack of functionality for the last year, I finally decided to do something about it. As a solution, I built a single page application (SPA) using my open source JavaScript framework alt-seven with a NodeJS backend, but I wanted to integrate the application into the static site into a seamless user experience. What to do?

Enter NGINX

If you work with application integration with web servers, you probably already understand this, but the short story is that it is relatively simple to integrate a Web application with a static site using a web server like NGINX. NodeJS serves my custom web app on a non-standard port (8181). It includes mapping for static content like some NodeJS modules that are used on the client side.

For the most part, all that needs to happen in order to integrate the app into the static site is to pick a path for the application inside the root of the static site that doesn’t occur in the static site. In my case, I chose the path /app. In the configuration file for my site on NGIX, I added instructions to pass through requests to this path to the web application.

In addition, there are a couple of other paths inside the web application that need to be mapped to pass through to the web application, so I added instructions for those pass through mappings as well.

The mappings in NGINX look something like this:

        location /api/ {
           proxy_pass http://127.0.0.1:8181;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Real-IP $remote_addr;
        }


        location /lib/ {
           proxy_pass http://127.0.0.1:8181;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Real-IP $remote_addr;

        }

        location /assets/ {
            proxy_pass http://127.0.0.1:8181;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Real-IP $remote_addr;
        }

        location /app/ {
            # internal;
            rewrite    ^/app/(.*) /$1 break;
            proxy_pass http://127.0.0.1:8181;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Server $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Real-IP $remote_addr;
        }

Note in the /app/ mapping I am using a rewrite rule to remove the /app prefix to the path before I pass it along. That gives the NodeJS application the path it is expecting ("/") as the root of the application.

There is one additional NGINX item to configure- allowing symlinks to resolve. Add this somewhere in your config. I added to the server config for my site:

disable_symlinks off;

You can then map the path you specific in the application (/app) as a symbolic link in your filesystem. This allows your application to live outside of the root of the static site while still being accessible from inside the static site.

Final Config Items

Add those configuration items, reload NGINX, and the application now integrates into the static site … almost. There is only one thing missing, but it’s rather important. The framework has a URL router to map URLs to events in the application so you can link directly to places in the single page application using specific URLs. Since the URL router looks at URLs in the browser navigation bar, that means I need to let the application know what the base URL of the site should be. As a quick and dirty solution, I added the base URL directly into the application, but that prevents me from running and debugging the application in a standalone manner outside the static site mapping. This URL opens the contact form on the site:

a7.router.open( "/app/contact/showform" );

And that’s great, but I want to be able to run the site independently of the static site. There are different ways I could configure the site with a variable base path. Configuration file or command line arguments are possible, but they require manual setup. Instead, if I can detect the base path when I first load the client side index page of the application, I don’t need to worry about setting the path manually, and it automatically resolves to the path I am using. My initial load of the application in the index page looked like this:

<script type="module">
	import {application} from '/assets/js/app.js';
	import {constructor} from '/lib/gadget-ui/dist/gadget-ui.mjs';
 
	var app = application();  
 </script>

Let’s add some code to detect the path and pass it to the application. I will set the path either to blank ("") or matching the “/app” path, depending on what the application call sees in the browser bar when it loads. That way I only have two possibilities for the path - the two options I am using for my purposes. My final change will look like this:

<script type="module">
	import {application} from '/assets/js/app.js';
	import {constructor} from '/lib/gadget-ui/dist/gadget-ui.mjs';

	var path = new URL(location.href).pathname;
	var basePath = path.match( /\/app/ ) || "";
	var app = application(basePath);
</script>

Now that I have passed the basePath into the application, I can set it into a global setting and access it whenever I need to use it to use the router to open a path. There is still one last setting I need to add- the optional router paths that the application will see depending on how it is accessed. In my application, these paths are contained in app.routes.js. Here is the final configuration:

export var routes = [
	[ '/contact/sendmessage', 'contact.sendMessage' ],
	[ '/contact/showform', 'contact.showForm' ],
	[ '/', 'main.home' ],
	[ '/gallery', 'gallery.show'],
	[ '/app/contact/sendmessage', 'contact.sendMessage' ],
	[ '/app/contact/showform', 'contact.showForm' ],
	[ '/app/', 'main.home' ],
	[ '/app/gallery', 'gallery.show']
];

The main downside of this solution is clear- I need to duplicate the routes based on which basePath the application is using. That’s not ideal, but for now I am happy to include two sets of paths.

The JavaScript code for the application on this site isn’t obfuscated, so if you would like to see in more detail how it works, feel free to pick through the source code. Drop me a line with any questions you have about this solution.