Explaining how I properly changed the permalinks on my blog, using Ghost's dynamic routing and more.

I’ve never used date in my blog posts URLs because I thought it looked nicer and was better for SEO.

Recently, I changed my mind and I find it better to have the date in the permalink. Not necessarily as precise as to put the day, but as least the year and month.

What I want to achieve is this:

https://angristan.xyz/understand-k8s/

->

https://angristan.xyz/2020/01/understand-k8s/

Ghost once had a toggle in the settings to enable dated permalinks, but it’s gone now and it’s off by default. However, it still does support it thanks to dynamic routing, which allows creating routes however you like through a YAML file.

In my case, I had to go through 3 steps to get the new URI scheme ready:

  • Update Ghost’s dynamic routing to use dated permalink
  • Update Ghost’s redirect engine to redirect to the new permalinks
  • Update Isso’s database to match the new permalinks

Configure dynamic routing

This one is the easiest. In the lab section of Ghost, upload a new routes files containing:

routes:

collections:
  /:
    permalink: /{year}/{month}/{slug}/
    template:
      - index

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

The only modified part here is /{year}/{month}.

Once that’s done, generated internal links (front page, suggested content, tags and author pages) will use the new permalinks. However, any link pointing the old permalinks will be broken. We have to set up permanent redirections.

Configure redirections

We could do this from our reverse proxy, but since Ghost has a redirect engine, we might as well use it and keep our configuration clean.

The redirect engine is not as smart as the dynamic routing and does not support slugs but only regexes.

Since I have dozens of post, there’s no way I’m gonna write a JSON file that long myself. Plus, I might make typos.

I made a quick and dirty python script to generate the redirects JSON file:

import urllib.request, json
from dateutil import parser

website = 'angristan.xyz'
content_api_key = ''

with urllib.request.urlopen('https://' + website + '/ghost/api/v3/content/posts/?key=' + content_api_key + '&limit=all') as url:
    data = json.loads(url.read().decode())
    redirections = []
    for post in data['posts']:
        published_date = parser.parse(post['published_at'])

        redir = dict()
        redir['from'] = post['slug']
        redir['to'] = published_date.strftime('%Y') + '/' + published_date.strftime('%m') + '/' + post['slug']
        redir['permanent'] = True

        redirections.append(redir)

print(json.dumps(redirections))

It’s not pretty but it works well!

We use Ghost’s content API here to fetch all the posts as JSON. To get a content API key, head to the Ghost admin and add a new integration. You’ll get a content API key. Don’t forget to delete the integration afterwards.

You can redirect the output of the script to a .json file.

It will look like this:

[
	{
		"from": "^/lg-27ul850-broken-usb-c/" ,
		"to": "/2020/02/lg-27ul850-broken-usb-c/",
		"permanent": true
	},
	{
		"from": "^/migrating-comments-from-isso-to-disqus/" ,
		"to": "/2020/02/migrating-comments-from-isso-to-disqus/",
		"permanent": true
	},

...

After uploading the file into the redirects part of the Ghost admin lab, all my old content was now being redirected to the new permalinks.

Update Isso’s database

I thought I was done, but I noticed that all my comments were missing from my posts. It’s because Isso uses the permalink as an identifier, so it couldn’t associate the comments with the new URIs.

If you’re using another commenting system, a similar operation might be needed.

I had to update the uri column of the threads table in the Isso database. Since I had to generate a bunch of SQL queries, I reused my previous script.

import urllib.request, json
from dateutil import parser

website = 'angristan.xyz'
content_api_key = ''

with urllib.request.urlopen('https://' + website + '/ghost/api/v3/content/posts/?key=' + content_api_key + '&limit=all') as url:
    data = json.loads(url.read().decode())
    for post in data['posts']:
        published_date = parser.parse(post['published_at'])
        slug = post['slug']
        new_slug = published_date.strftime('%Y') + '/' + published_date.strftime('%m') + '/' + slug

        print('UPDATE "threads" SET "uri"="/' + new_slug + '/" WHERE "uri"="/' + slug + '/";')

It will generate something like this:

UPDATE "threads" SET "uri"="/2020/02/lg-27ul850-broken-usb-c/" WHERE "uri"="/lg-27ul850-broken-usb-c/";
UPDATE "threads" SET "uri"="/2020/02/migrating-comments-from-isso-to-disqus/" WHERE "uri"="/migrating-comments-from-isso-to-disqus/";
UPDATE "threads" SET "uri"="/2020/01/understand-k8s/" WHERE "uri"="/understand-k8s/";

After backing up my database, I just had to run:

sqlite3 comments.db < out.sql

Done. That’s how I properly changed the permalinks on my blog.

The only thing “missing” is analytics. My stats (Matomo, Google Analytics) are going to be kind of flawed from now on because these pages will count as new pages. But this part is not critical for me, I don’t rely on analytics that much.