AngularJS HTML5 mode and Rails routes

AngularJS is a super cool JS app framework. I'm not going to write much about AngularJS but you should check it out if you haven't used it.

Most of my AngularJS apps are backed by a Rails API app. This is a pretty good fit since you get all the power of Rails ecosystem and with some minor tweaks you can slip Angular into the asset pipeline.

Normally AngularJS will use URL fragments to track its in-app routing, shown as /#/my/route in your addressbar. You can enable HTML5 on the $location service which would convert the above URL into /my/route which you might find more aesthetically preferable.

// configure our location provider to be in HTML5 mode
angular.module('sweetapp').config(['$locationProvider', function($locationProvider){
  $locationProvider.html5Mode(true);
}]);

The pitfall of HTML5 Mode

With your app in HTML5 mode, try navigating around to a route and then refreshing your browser. Now that your in-app routes don't have any hash fragment, reloading the page will be treated as a direct get request. See the walkthrough below for an example of what is happening.

HTML4/Fragment mode:

User refreshes browser at "http://myapp.dev/#/my/route"

  => GET '/', rails serves index route
  => Browser inserts fragment '#/my/route'
  => AngularJS picks up on fragment and routes to correct controller/view

HTML5 mode:

User refreshes browser at "http://myapp.dev/my/route"

  => GET '/my/route', rails attempts to serve '/my/route'
  => Rails serves 404 because no route is found
  => AngularJS is never served

We can remedy this by adding a catch all Rails route to redirect to the index action with the previous route attached to the URL as a query param.

Fixing your Rails routes

All you have to do to your rails route is add one line. Note, this route should be the second last in your routes.rb file, just before your root to: line.

#   
match "api" => proc { [404, {}, ['Invalid API endpoint']] }
match "api/*path" => proc { [404, {}, ['Invalid API endpoint']] }

match "/*path" => redirect("/?goto=%{path}")

The route will redirect any unmatched requests (remember routes are matched in the order the are defined) to our site index, but we'll attach the path that was requested onto the URL. With the old path there we can handle it in our AngularJS app.

Note: You could perform a similar redirect with nginx (etc) however it wont be aware of your other Rails routes. Depending on how you're managing your app, this can be easy to fix. All my API endpoints are behind /api/vN/... so its easy to make nginx ignore those.

Fixing your AngularJS routes

Just as easy as before, we need to update our index route to check if the query param contains a redirect request.

angular.module('sweetapp').config(['$routeProvider', function($routeProvider) {
    $routeProvider.
      when('/', {
        templateUrl: "...",
        controller: "...",
        // Add our redirection handler, normally this is used
        // in otherwise routes, but we can co-opt it here
        redirectTo: function(current, path, search){
          if(search.goto){
            // if we were passed in a search param, and it has a path
            // to redirect to, then redirect to that path
            return "/" + search.goto
          }
          else{
            // else just redirect back to this location
            // angular is smart enough to only do this once.
            return "/"
          }
        }
      }).otherwise({redirectTo:"/"});
}]);

And now we'll get the behaviour we wanted, users can refresh a page (or paste a link, etc) and they'll be redirected to the correct route. As said in the comments, you will incur a redirection when the user requests "/", but it only happens once and the trade off is worth it in my opinion.

HTML5 mode with fixes:

User refreshes browser at "http://myapp.dev/my/route"

  => GET '/my/route', rails redirects to "/?goto=my/route"
  => Rails serves "/"
  => AngularJS recognises our custom redirection
  => AnguarJS redirects to "/my/route" and serves correct controller/view

Written 29th of May, 2013