Update 4: The method is slightly different for Ghost 0.11.0, which can be seen here.

Update 3: Nothing has changed with Ghost 0.5.5, the below still applies.

Update 2: Ghost 0.5.3 requires both the original code and the changes 0.5.1 required.

Update: This fix applies to Ghost 0.5.1, but requires an additional step if your site is running in production mode.

When I changed my blogging platform from WordPress to Ghost, I was a little disappointed with the lack of features that I would have designated as important, such as sitemaps and categories. In Ghost, tags and categories are treated as one and the same, which was a deal breaking issue. My site, and so many others, have been up for years and have a long history of backlinks. I did not want to break those links, especially to Autotab. In WordPress, I used categories to organize my blog posts, reviews, portfolio and sandbox, so my URLs came out like the following:

  • /blog/hello-world
  • /sandbox/jquery-autotab
  • /portfolio/freelance

Before I finally made the switch to Ghost, Tim Strimple provided me with a fix that allowed me to fake the category by allowing slashes in a post's URL. Unfortunately, the fix only applied to 0.4.0. Version 0.5.0 forced me to create a poor fix that broke the new /edit redirect. However, I was finally able to come up with a solution that supported the desired URL structure while also keeping the /edit redirect.

We need to change the behavior of single pages to support the URLs listed above. In /core/server/controllers/frontend.js, under the single item, we need to check the path, update the _.pick call to include the category, and then modify postLookup.slug. Click here for the GitHub diff for this file's change.

'single': function (req, res, next) {
    api.settings.read('permalinks').then(function (response) {
        ...

        // A category exists in the URL
        if (path.split('/').length - 1 > 2) {
            permalink.keys.unshift({
                name: 'category',
                optional: false
            });

            permalink.route.path = '/:category' + permalink.route.path;
            permalink.regexp = /^\/(?:([^\/]+?))(?:\/([^\/]+?))(?:\/([^\/]+?))?\/?$/i;
        }

        ...

        // Sanitize params we're going to use to lookup the post.
        postLookup = _.pick(permalink.params, 'category', 'slug', 'id');
        // Add author, tag and fields
        postLookup.include = 'author,tags,fields';

        if (typeof postLookup.category !== 'undefined') {
            postLookup.slug = postLookup.category + '/' + postLookup.slug;
        }

        // Query database to find post
        return api.posts.read(postLookup);
    }).then(function (result) {

In /core/server/utils/index.js, we want to prevent forward slashes from being removed in a blog post. In the safeString item, remove \/ near the beginning of the regular expression.

string = string.replace(/[:\?#\[\]@!$&'()*+,;=\\%<>\|\^~£"]/g, '')

You may be good to go at this point, which you can test by adding a foward slash to the post's URL. If you are testing the change locally, be sure to verify it on your server as well. Using only the above changes, my local copy worked without issue, but not when I tested the change on my Azure copy. If you hit an error, you'll probably see this: Cannot read property '0' of undefined. Looking at the logs, the forward slash in the new URL is being decoded before the server receives the request, giving the request an unintended additional parameter. You can read more about it on my StackOverflow question.

Still with me? Then you're probably seeing the same issue. Here are two changes that are not encouraged, but if you are feeling adventurous, here goes.

In /core/server/routes/api.js, remove the :name parameter in the Slugs area:

// ## Slugs
router.get('/slugs/:type', api.http(api.slugs.generate));

In /core/built/scripts/ghost-dev.js, we need to change the URL in the GET request. Modifying this file is not encouraged as it is a compiled script. The change is simple enough in that it forces the value into a parameter, preventing it from being prematurely decoded. Unfortunately, this was the only solution I could come up with to fix the behavior I described in my SO question.

define("ghost/models/slug-generator",
    ...
    var SlugGenerator = Ember.Object.extend({
        ...
        generateSlug: function (textToSlugify) {
            ...
            url = this.get('ghostPaths.url').api('slugs', this.get('slugType')) + '?name=' + encodeURIComponent(textToSlugify);

Click here for the GitHub diff for these last two changes, which contained a typo in the parameter name, so don't repeat my mistake.

0.5.1+ Update

The release of 0.5.1 introduced several new minified files. If your site is running in production mode, you'll need to modify /core/built/scripts/ghost.min.js in order to be able to save URLs with forward slashes. Since it's minified code, I'll include a brief snippet of the resulting change, but your best best would be to copy and paste the resulting file that I'm using on this site.

return a?(b=this.get("ghostPaths.url").api("slugs",this.get("slugType"))+'?name='+encodeURIComponent(a)

Now you are ready to customize your URLs with fake categories. The worst part of this setup is having to redo them every single time Ghost is upgraded because they treat tags as categories, which is very limiting and, in my opinion, poorly thought out. I want proper category support because I have pages that represent the categories, with links to drill down further, as is the case with my Sandbox and Portfolio pages. Maybe one day Ghost will support categories, but I'm not holding my breath. I hope this helps!