Links in a single page app

February 18, 2014

Updated: Since the original posting, this has been updated with some corrections thanks to Dan Pupius.

Some websites benefit from the single page application approach, which lets users navigate within the site without each navigation being a full page load. Gmail is one obvious example. But historically, the techniques used to implement single page applications have also made it easy for implementers to make a frustrating site: one where URLs don't make sense, where the back button doesn't work, or where links don't behave like real HTML links.

Here's an approach to implementing the single page pattern on today's web that makes it easy to get it right. It's based on three principles.

  1. Every view must have a real URL. On a normal web page, at any time I can grab what's in my browser's URL bar and either share it or bookmark it.

  2. Every link must be a real link. On a normal web page, I can view a link's destination by hovering it with my mouse; right-clicking produces a menu of link-specific options. So many otherwise-good pages try to implement their own link-like behavior by catching clicks on specific DOM nodes but then screw up corner case behaviors like middle-click.

  3. It's ok to be less awesome on old browsers. (Of course, whether this is actually true depends on your site's goals.) One tactic for old browsers is just to fail gracefully: if the site still works but is just slower, that's ok. For example, Gmail used to implement each of its buttons with a soup of DOM nodes to get the gradients and rounded corners to show on IE6; on today's web, maybe it's ok to just use some newer CSS for those effects and allow the buttons to be square and flat on IE6.

Now let's put these constraints together. Because of principle 1, your app must be capable of rendering any given view from scratch, because the URL load might be a freshly started browser that just loaded a bookmark. Because of principle 2, your links must be implemented as plain old <a> tags with an href that points at the URL of the resulting view.

In fact, you could stop there and you'd have a working site: each link click would just load the target as a new URL. That satisfies principle 3.

And here, finally, you can sprinkle in the single-page magic. What you want to do is short-circuit some of those link clicks such that the page updates. You can do this in one central place by just intercepting clicks on <a> tags right before the browser is about to use them to navigate, and then use the history.pushState API to make the URL update appropriately.

// Left to the reader: imagine some "app" object with methods whose
// implementations will be obvious from their use.
var app = ...;

// Catch clicks on the root-level element.
document.body.addEventListener('click', function(event) {
  var tag = event.target;
  if (tag.tagName == 'A' && tag.href && event.button == 0) {
    // It's a left click on an <a href=...>.
    if (tag.origin == document.location.origin) {
      // It's a same-origin navigation: a link within the site.

      // Now check that the the app is capable of doing a
      // within-page update.  (You might also take .query into
      // account.)
      var oldPath = document.location.pathname;
      var newPath = tag.pathname;
      if (app.capableOfRendering(newPath)) {
        // Prevent the browser from doing the navigation.
        e.preventDefault();
        // Let the app handle it.
        app.render(newPath);
        history.pushState(null, '', path);
      }
    }
  }
});

// Also transition when the user navigates back.
window.onpopstate = function(event) {
  app.render(document.location.pathname);
  event.preventDefault();
};

Some additional notes: