Pretty Permalinks with Angular UI-Router

February 23rd, 2017

Seattle’s Aurora bridge, cyanotype print by Peter Mumford

Problem: how to get WordPress-style pretty permalinks into an Angular app? I want my permalinks to have this format:

I want single posts and category and tag archives to contain slugs, not IDs in the URL.

This is not hard to achieve with Angular UI-router. This is how I do it. First I define my routes this way, on ui-router’s stateProvider:

$stateProvider.state('app', {
    url: '/?page',
    params: {
      page: {
        value: '1',
        squash: true
    component: 'postsInCollection'
  .state('collection', {
    url: '/:taxonomy/:slug?page',
    params: {
      page: {
        value: '1',
        squash: true
    component: 'postsInCollection'
  .state('single', {
    url: '/:year/:month/:slug',
    component: 'singlePost'

You can see that there are only three states:

  • A default, or home state. This picks up the default posts, and it has a pagination parameter.
  • A collection state that gets a set of posts in a taxonomy term. The term is passed by slug, not ID. Note that the collection state and home states share the same component (and the same controller).
  • A single-post state. It passes year, month, and slug values in the URL.
  • The squash property in the params object. This means that if your parameter has the default value, in this case 1, it will not be shown in the URL. In practice, that if you are on page 1 of a collection, it won’t show pagination. Only subsequent pages have the ?page=2 string.

To make this work we need to have the year and month values correctly formatted in our data. I do this in the postsInCollection controller:

var WPAPI = require('wpapi');

module.exports = {
  controller: collectionController,
  template: require('./collection.html')

/** @ngInject */
function collectionController($stateParams, $scope, $filter, $log, $state) {
  var SELF = this;
  var wp = new WPAPI({
    endpoint: ''

  // Pagination functions = parseInt($ || 1;
  SELF.nextPage = function() {
    $state.go('.', {page: + 1}, {notify: false});
  SELF.previousPage = function() {
    $state.go('.', {page: - 1}, {notify: false});
  // success handler
  var success = function(response){
    // loop through the response to make any changes
    for (var i = 0; i < response.length; i ++) {
      // filter the date so it can be used by the router
      var postDate = response[i].date;
      response[i].year = $filter('date')(postDate, 'yyyy');
      response[i].month = $filter('date')(postDate, 'MM');
    // after processing, add the response to the controller
    SELF.posts = response;
    // apply the data to scope

  // error handler
  var fail = function(error) {

  // setup the query if the taxonomy slug is in the URL
  if ($stateParams.slug) {

    if ($stateParams.taxonomy && $stateParams.taxonomy === 'category') {
      // first use the slug to lookup the category ID
      wp.categories().slug($stateParams.slug).then(function(cats) {
        // then use the ID to get the posts
        return wp.posts().categories(cats[0].id).param('page',;
      }).then(success, fail);

    } else if ($stateParams.taxonomy && $stateParams.taxonomy === 'tag') {
      wp.tags().slug($stateParams.slug).then(function(cats) {
        return wp.posts().tags(cats[0].id).param('page',;
      }).then(success, fail);
  } else {
    // if there is no taxonomy slug, get the default posts
    wp.posts().param('page',, fail);

A few things to note in this code:

  1. It requires the utility node-wpapi. This utility queries the wordpress database.
  2. In the success handler, I filter each posts’ date object, to get simple strings for the year and the month, and add those data properties to the response.
  3. After processing the data, I add it to the controller, and then apply it to the scope.
  4. There are conditionals that format the node-wpapi query differently, depending on what parameters are passed in the state. This lets me get a list of tagged posts, or posts in a category, or just the default posts if no taxonomy slug is passed.
  5. There are pagination functions that get a new set of posts without reloading the controller. That’s what notify: false does. Sweet!

Now in the template for collections, I can make links from the collection page to the single page this way:

<div ng-repeat="post in $ctrl.posts">
  <a ui-sref="single({year: post.year, month: post.month, slug: post.slug})" 

To make a link to a collection (for example a category archive), write it this way:

<a ui-sref="collection({taxonomy:'category', slug:'books', page:1})">Books<a>

And, also in your collection page, you can put in a older posts link this way. It calls the nextPage function that is defined in the collection controller. And the conditional means it won’t appear if there is no next page:

<a ng-if="$ < $ctrl.posts._paging.totalPages" ng-click="$ctrl.nextPage()" href>older posts</a>

Did I forget anything important? Probably.. BTW, the codebase is at github/photocurio.

Leave a Reply

Your email address will not be published. Required fields are marked *