Lazymorphic Apps: Bringing back the static web
We’ve come long way from the simple static sites of the early web, yet somehow, I really miss it.
Of course we can still do that for simple static content, but what about native web apps (a.k.a. single page apps).
But, many times as an app grows beyond the prototype stage, developers are quick to convert what is otherwise a completely static set of files into an app that also has a dynamic server-side process.
Usually this is because of a few challenges that I’ll iterate through and discuss below.
I’ve found that most of these reasons are easily addressed, and doing the work of keeping your app as a static set of files has some significant benefits.
If we serve a native web app from a completely logic-less static file server this usually means ugly URLs in order to have them match specific files.
So, instead of:
You may think we’d have to do:
However, that’s fairly easy to address by having a bit of logic in a routing config that implies the
.html. Many static file servers do this easily or even by default.
But, perhaps more importantly, a completely logic-less file server will only answer requests that match specific HTML documents on our server. Without some kind of “catchall” there’s no way to truly do clientside routing in our native web apps.
As a quick example you may want a URL like this in your app:
But unless your folder structure contains just such a file structure:
www/products/widgets/42.html for example, you may not have a match. Also, you’ve now coupled your file structure to your URL scheme. Which may not be ideal.
We can address that with a little bit of routing logic too.
Imagine if our routing rules looked as follows:
1. Specific assets are just matched to structure
route: file served: site.com/pic.png -> pic.png
.html is implied
route: file served: site.com/page -> page.html site.com -> index.html
3. Unmatched route, without file extensions, handled by catchall
If a route that includes a file extension (such as
.png) is requested, but missing, respond with a 404 code as usual.
But, if something that looks like an application path (no file extension) is requested, always respond with whatever file you’ve specify as your catchall. Perhaps a
200.html or a
catchall.html from the root of your static file folder.
route: file served: site.com/missing-image.png -> 404 site.com/other-path -> 200.html
With just this bit of logic we can now serve a completely static set of files in a way that enables beautiful, clean URLs in our native web apps.
The logic I’ve described above is exactly how Surge is configured out of the box. But it’s also not too difficult to create similar functionality in other routing systems. Using nginx’s try_files directive, for example.
I hope to see approaches like this become standard config options in file servers. It would be cool to see GitHub Pages add support for a 200.html file, for example.
I’d argue that in today’s web, if you’re writing any significant amount of JS it should probably go through a build step before being served to a browser anyway, at least for minification, if nothing else.
But the need for a build step becomes even more apparent if you’re building an entire application as we’re describing here.
You don’t want to have to write everything in just a couple of files, or have 40 different
<script> tags in your HTML. It’s incredibly hard to stay organized that way.
We’ve been using npm and CommonJS modules for frontend code at &yet for years. Since browsers don’t have a
require function by default, CommonJS modules can’t be used in a browser without some kind of shim so we’ve become quite accustomed to build-steps anyway and at this point, we’re never going back.
Also, with the emergence of ES6, we’ve also been adding a transpilation step to that build step to transpile ES6 into code that browsers can run (using babel.js).
More on how I’ve been doing the builds below, but first...
This seems to always come up despite the fact that rendering an app entirely in JS is no longer considered a problem for accessibility (read this post by Paul Irish) as 98.6% of screenreaders in use (as of 2012) handle JS just fine.
I’d encourage you to read that article for more detail. But making things keyboard accessible is far more important that avoiding JS.
This seems to be the most oft-mentioned concern with building applications that render content after the JS has executed. This is true. Pure JS-rendered sites can be plagued by this.
A few points to consider:
- Most of the cases where developers are wanting to build a native web app the use case requires a login anyway. For those cases, SEO is only relevant to the public pages, which I would suggest not rendering on the client anyway.
- This whole no-JS-for-SEO argument is losing steam as search engines are getting better at executing JS before indexing the content. This article by Ben Galbraith and Dion Almaer suggests that JS-capable search is clearly the future and perhaps it’s time to skate where the puck is going to be.
- We can address this in full or in part by rendering all known HTML to static files ahead of time (more on this later in this post).
Because the browser always gets “empty” HTML, it has to download, parse, and execute the entire JS payload before the user sees anything at all on their screen.
Browsers have been optimizing HTML rendering performance for years, turns out. If a server responds with a full HTML document and some CSS, browsers can turn that into a viewable webpage extremely quickly. That is, unless you do something to block it from doing so.
The trouble with native web apps in this regard is that many people who build them serve “empty” HTML files, with a single
<script src="app.js"></script> that includes the whole app.
If you’re not familiar with isomorphic applications, the term “isomorphic” could arguably be better explained as “portable JS” meaning that the code is written in a way that enables it to run in something like Node.js on the server and also work in a browser.
You can imagine if you build your entire application in such a way that it didn‘t care where it ran, you could simply run it on the server first and instead of responding with “empty” HTML, respond with the HTML that your app would have generated in the browser for that particular URL anyway.
As it turns out, however, those environments are often not very similar. Browsers have things like
document, and a whole lot of browser APIs that simply wouldn’t have any place in a serverside environment (no need for
navigator.getUserMedia for example).
We also have to figure out a way to run a somewhat isolated browser-like environment for each user if we have any user-specific data we also want to include. Of course, all these things can be done. But it’s complex, resource intensive, and frankly doesn’t feel like a reasonable expectation to set as a “best practice” for every app, nor as something that should be considered required knowledge for anyone wanting to build a “socially acceptable” web app.
What if instead of trying to render everything, what if we at least rendered everything we know at build time?
Using the routing logic described above and something like React that lets us efficiently morph the existing HTML (from our static HTML file) we could just pre-render all known HTML structure at build time.
For funsies I’m referring to this technique as Lazymorphic rendering. It’s not running a full isomorphic application in production, instead we simply pre-render as much of the application as we possibly can to static files at build-time.
We don’t want to devolve to a mess of complex Grunt or Gulp configs either, but clearly, in order to build things in this way, we need a workflow that allows us to easily transition from development mode to bundling everything up for production.
We really don’t want to introduce a slow build step that we have to run each time we make a change. Webpack and the webpack-dev-server are good tools to allow for this type of thing. Unfortunately, setting it all up in this way requires a lot of finagling with webpack configs.
To make that easier I’ve open sourced a module called hjs-webpack that basically just pre-configured webpack and a dev server. It makes setting up the workflow I’ve described a lot easier. There’s also a screencast that walks through setting it up and lets you see what it actually does.
Like many native web apps it has some public marketing pages and then all the “app” experience portions that exist behind the login prompt.
In this simple example app we end up with two files at build time:
- index.html: the public marketing page
- 200.html: the HTML that is shared among all the app pages. Things like layout, CSS, main navigation, etc.
If we followed the routing rules above, this means that
site.com would serve the index file and
200.html would be served at
Maybe. What if we did this even for dynamic, but public data? Things like product pages in an e-commerce site could arguably be generated to pre-rendered to static files at build time too, right?
I mean, why not, right?!
Unless it’s user-specific or secured data, we could just generate pages for each of those products at build time too. If we do that we could even bootstrap the structured data we know that our JS is going to need by rendering the JSON into a
<script> tag page while we’re generating the HTML.
I haven’t yet tried this on something with a lot of public pages, such as an online store, but it’s worked beautifully on things we have used it for.
Just think about what we get:
- Pixels on the screen immediately
- Replace front-end servers with a CDN that serves static files, faster delivery, less likely to have downtime.
- Any known public content is available statically, even without JS.
- A nice development workflow
- Totally crawlable public pages
- JS takes over when downloaded and fills in any dynamic portions we don’t already have.
- If we use regular
<a>tags for navigation, even internal navigation works without JS, but if we do have it, JS intercepts the clicks and navigates internally.
- Dramatically simplified ops and deployment. You could just deploy with something like rsync. Or use a service like Surge.sh or Divshot to do it for you.
- No complex isomorphic application we have to run in production, yet we still get many of the benefits.
- By nature of the approach we’ve arguably built an app that’s offline-ready, in that it has very clearly cache-able assets and fetches all its data from external sources.
- We’re already prepared if we want to distribute the app in a wrapper like PhoneGap, Cordova, etc.
If we don’t have a server that we’re running in production how do we handle logins and sessions?
If you’ve built against a 3rd party API before you may have implemented a web application flow for something like OAuth. This can also be handled entirely without a server.
Redirects are easy:
window.location = 'http://newsite.com' and we can make AJAX requests to fetch tokens, etc. Once we have an API token, we can use that to make authenticated requests to the API server.
Authentication tokens can be safely stored in a browsers’s
localStorage. Doing it all this way isn’t necessarily any harder, it’s just different that using a server framework’s built-in session systems.
This is demonstrated in the open source HubTags app app I mentioned before.
If you’ve done OAuth before or are security minded, you’re already predicting the next section:
If we compile everything to static files we can’t store secrets in them, right? Not in plain text, no.
But we can run micoservices that are responsible for keeping our secrets for things like OAuth Client Secrets. To demonstrate this, the HubTags app does OAuth with GitHub using a little micro-server whose only purpose is to keep our Client Secret, well... secret. You can deploy your own one for free to Heroku literally by clicking this button.
Another cool approach to this kind of thing is webtask.io. It uses JSON web tokens to encrypt secrets in a way that makes it safe for them to part of the static code in your app. You write a Node.js script that exists at a URL and your secret data is encrypted into the tokens you generate.
Of course, by building this way, all the dynamic data that we can’t know at build time has to come from an external API. I see this as a feature. Building in this way helps you separate presentation concerns for data/API concerns. Your web app becomes a true standalone native web app that sits right alongside any other apps you’ve built for your service.
Since all dynamic data is external, there will inevitably be some additional network requests required to fetch the dynamic data, but we can mitigate a lot of this by caching things like user data locally and trusting caches first, just like we’d do in a native app.
New technologies like ServiceWorker will grant us even more power in this regard.
But, we can optimize a lot of this kind of stuff just by caching JSON responses in
localStorage and including/checking timestamps that we store along with that data. Some tools like FireBase will even do this for you seamlessly for things like user authentication data.
Or if you want to talk in person sign up for a short office hours that I’m hosting on Thursday morning at 9 PT this week. I’m going to do a short 30-minute online presentation covering some of this same material, then we’ll have a discussion about it. Would love to have you join me.
You can also hit me up on Twitter at @HenrikJoreteg. Would love to hear your thoughts and feedback on this.