Angular's pretty URLs with Sails.js

Stas Demchuk

by Stas Demchuk on 10/07/2015

Pretty URLs

Recently, I've spent some time figuring out the best way to wire up AngularJS with Sails.js. You'll have no problems making them work together if you use hashes in your URLs. The thing is, hashes were used to bypass the bug with old browsers not supporting HTML5. They have more drawbacks than you might think, so it's generally a good idea to use HTML5 History API.

The problem is, when using HTML5 Mode for URLs in Angular, request will go to the server first, and without proper handling, Sails will try to find a view for this URL and render it. It will result either 404 or not the view you wanted to see.

There are several ways you can make $locationProvider.html5Mode(true) work with Sails.js properly.

App policy

A nice approach, which requires some work in both Sails and Angular code. The idea is to have Sails always render the index.html if the request comes from browser and send appropriate JSON data if it is an AJAX request made by Angular. In Sails app, you should create a new policy named isAjax.js and add following code to it:

module.exports = function(req, res, next) {
  if (!req.xhr) {
    return res.view("/index");
  }

  return next();
};

After that, in config/policies.js, add this policy for all the requests:

module.exports.policies = {
  '*': 'isAjax',
};

That's all you need to do on the backend. Now you need Angular to send a X-Requested-With header, otherwise the policy will just send index.html in response to all your requests. Angular was actually doing it by default, but they removed this behavior to prevent triggering preflight checks for crossdomain requests. Fortunately, to get it back, you just need one line in your app config file:

myAppModule.config(['$httpProvider', function($httpProvider) {
    $httpProvider.defaults.headers.common["X-Requested-With"] = 'XMLHttpRequest';
}]);

If the value of this header is 'XMLHttpRequest', req.xhr will be true and return next(); in our policy will send the request down the pipeline of middlewares.

Wildcard Sails route (referenced here)

This approach is great if you want to have several Angular apps working with the same backend (I know, it might sound awkward, but we actually did this in YeahDog, but with ASP.NET MVC on the backend). In order to make it work, you'll need to setup a wildcard route in Sails:

'/user/*': 'UserController.index',
'/tag/*': 'TagController.index',

After that, just configure the routes in your config file with that prefix:

.config(['$routeProvider', '$locationProvider', function ($routeProvider, $locationProvider) {
    $routeProvider.when('/user', {templateUrl: '/angular/application/views/settings/user.html', controller: 'settingsUserCtrl'});
    $routeProvider.when('/tags/:tagId', {
        templateUrl: '/angular/application/views/tags/tag.html',
        controller: 'tagListCtrl',
        reloadOnSearch: true
    });
    $routeProvider.otherwise({redirectTo: '/app/user'});
}]);

Now NgRoute will render a proper view even if the user used a direct link to access the page.

Regex route

This approach will work for you if you're prepending all your API endpoints with a prefix, like /api. Long story short, I was struggling to find a better solution with less code for this when we worked on generation of Node.js apps in KAPTL. Turns out, Sails not only allows you to use wildcards when setting up your routing, it also provides a way to define a route using a regular expression. All I had to do is to define a route in config/routes.js:

"r|^\/(?!.*api).*|": {
  view: 'homepage',
  skipAssets: true
}

You can read it as "if the request URL doesn't start with /api, render index view". This regex performs a negative lookahead of the string passed to it and resolves to true when the URL is not an API endpoint. skipAssets: true just checks if it's a URL to a static resource, and responds with proper content instead of index page content.

Had any problems making Angular and Sails work together? Let us know in the comments below!