A better related block for Ghost

Many themes offer a "read more" or "related posts" section at the bottom of each post. I unpack what it's doing, why yours might not be working so well, and some possible fixes.

A better related block for Ghost
These Ghosts might be related?

Here's an example, from Source (at the end of post.hbs):

{{#get "posts" include="authors" filter="id:-{{post.id}}" limit="4" as |next|}}
    {{#if next}}
        <section class="gh-container is-grid gh-outer">
            <div class="gh-container-inner gh-inner">
                <h2 class="gh-container-title">Read more</h2>
                <div class="gh-feed">
                    {{#foreach next}}
                        {{> "post-card" lazyLoad=true}}
                    {{/foreach}}
                </div>
            </div>
        </section>
    {{/if}}
{{/get}}

Note that exclusion of the current post.id. That's a good feature - no sense in recommending the post the reader just finished! But there's nothing else here to adjust what comes out, so you're going to get the most recent four posts, excluding the current one.

That's OK as a "read more" (which is how it's labeled), but clearly a failure as a "related posts".

To serve higher interest posts, consider converting the filter to filter="id:-{{post.id}}+featured:true - now you're suggesting your recent featured content. Mark your favorite posts as featured, and they'll show up in this modified "read more" block.

If you publish on many different topics, you probably want to offer the posts that might most interest the reader who has just finished reading this specific post.

Here's how Ruby does it:

    {{#get "posts" limit="4" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags,authors" as |related|}}
        {{#if related}}
        <section class="related-posts gh-inner">
            <h3 class="related-title">
                <span class="text">You might also like...</span>
            </h3>
            <div class="post-feed">
                {{#foreach related}}
                    {{> "loop"}}
                {{/foreach}}
            </div>
        </section>
        {{/if}}
    {{/get}}

The key difference from Source is that Ruby is retrieving only posts that share a common tag with the main post.

Whether or not the Ruby approach works well depends a LOT on the tagging strategy. If your tags are all content-related and you have a "reasonable" number of them, this works great. But if you have a "#contain" tag that you use to tell Ghost that a particular post's feature image needs to be set to object-fit: contain, well, you're out of luck. Or maybe you have #newsletter and #blog tags to separate your content into two collections. These things take the related block back to being a "recent posts" block. Or maybe you have 95% of your posts tagged "Ghost". Oh wait, just me?

The "You might also like" block from vanilla Ruby. These posts have nothing to do with my post about replacing {{ghost_head}}, except they all have the #contain layout tag. (Two aren't even about Ghost!)

Or lets suppose we publish a travel diary. Posts are tagged something like "Europe", "Barcelona", "Travel". When your reader finishes a post about Barcelona, you'd like to serve up related posts that are also about Barcelona, not a random assortment of all the posts about travel. Switching the filter above to only get posts with the primary tag (instead of all tags) is a possible solution, IF you happen to have put your most specific tag first, in a consistent way. [If you're just starting out, you should consider doing that!] But even the primary tag approach breaks if your most specific tag doesn't have enough posts in it. If you have just one post about Barcelona, then it probably does make sense to show some posts about Europe, but if you're only grabbing posts by primary tag, you won't get the ones about Europe! filter="id:-{{post.id}}+primary_tag:[{{post.primary_tag.slug}}]" or filter="id:-{{post.id}}+tags:[{{post.primary_tag.slug}}]" are possibilities for using primary tags, with those caveats.

So... here's an improved 'related posts' strategy, using handlebars:

in post.hbs:

{{> related reltags=post.tags exclude=post.id}}

We're passing in the tags on the post (as reltags) and the ID of the post (as exclude).

in partials/related.hbs

{{#get 'tags' filter="slug:[{{reltags[*].slug}}]+visibility:public" limit="3" include="count.posts" order="count.posts asc" as |toptag|}}

{{#if toptag.[0].slug}}

    {{#match toptag.[0].count.posts ">" 4}}
      {{!-- The rarest tag has enough posts - go with that --}}
      {{> related-single inslug=toptag.[0].slug exclude=../exclude  }}
    {{else}}
      {{!-- there are not enough posts with the rarest tag --}}
      {{> related-two inslug=toptag.[0].slug exclude=../exclude inslug2=toptag.[1].slug inslug3=toptag.[2].slug }}

    {{/match}}

{{else}}

    {{!-- no tags, just grab some posts to show --}}
    {{> related-simple exclude=../exclude}}
    {{/if}}
{{/get}}

Let's break it down.

  • The first #get is getting only the public tags that are attached to the post (reltags). And it's sorting tags from least to most posts. So overall, I get the three least-common tags for the post, as "toptag"
  • Then, we check that there was at least one tag. If there wasn't (the else statement), then we just grab some recent posts, so at least the section isn't empty. (partials/related-simple.hbs would look like the recent posts code above.) Otherwise, we check to see if the first (least common) tag has more than four posts. If it does, we use that tag to get the related posts, passing in that first tag to related-single. If the first tag is really rare (maybe it's only used on this one post), then we pass in all three rare tags to "related-two"

Why the heck are we making this horrible mess of nested partials? Because we need to pass in exclude (the original post ID) from a few contexts up, but the filter does not accept the {{../exclude}} syntax. So we pass exclude into the context where we need it to be by creating a partial. Repeatedly.

In related-single.hbs

{{#get 'posts' filter="visibility:public+id:-[{{exclude}}]+tags:[{{inslug}}]" limit="4" include="tags,authors" as
|relposts|}}
<section class="gh-container is-grid gh-outer">
    <div class="gh-container-inner gh-inner">
        <h2 class="gh-container-title">Read more</h2>
        <div class="gh-feed">
        {{#foreach relposts}}
            {{> "post-card" lazyLoad=true}}
        {{/foreach}}
        </div>
    </div>
</section>
{{/get}}

We're grabbing four posts with the target tag (passed in as inslug), and excluding the original post ID (exclude).

In related-two (which actually does three).hbs

<section class="gh-container is-grid gh-outer recommended">
    <div class="gh-container-inner gh-inner">
        <h2 class="gh-container-title">Read more</h2>
        <div class="gh-feed">
            {{!-- get posts with first tag --}}
            {{#get 'posts' filter="visibility:public+id:-[{{exclude}}]+tags:[{{inslug}}]" limit="4" include="authors" as
            |relposts|}}
            {{#foreach relposts}}
            {{> "post-card" lazyLoad=true}}
            {{/foreach}}
            {{/get}}


        {{!-- get posts with second tag --}}
        {{#if inslug2}}
            {{#get 'posts' filter="visibility:public+id:-[{{exclude}}]+tags:[{{inslug2}}]+tags:-[{{inslug}}]" limit="4" include="authors" as
            |relposts|}}
            {{#foreach relposts}}
            {{> "post-card" lazyLoad=true}}
            {{/foreach}}
            {{/get}}
        {{/if}}
        
        {{!-- get posts with third tag --}}
        {{#if inslug3}}
            {{#get 'posts' filter="visibility:public+id:-[{{exclude}}]+tags:[{{inslug3}}]+tags:-[{{inslug}},{{inslug2}}]" limit="4" include="authors" as
            |relposts|}}
            {{#foreach relposts}}
            {{> "post-card" lazyLoad=true}}
            {{/foreach}}
            {{/get}}
        {{/if}}
        </div>
    </div>
</section>

I considered making this code look better by using more partials, but decided my head might explode if I did...

To summarize the mess above: Get four posts (although there won't be four) with the rarest tag, then get four posts with the second tag that do not have the first tag, then get four posts with the third tag that don't have the first or second tag. And in each case, I exclude the original post ID.

Whoa. Did we just get 12 posts? Yeah, maybe. The problem is that it's tough to get the number of posts returned outside of the #get statement, and there's no math in Ghost's version of handlebars. I tried to write something that figured out how many posts I got from the the first tag, and then to figure out how many to request from the second tag and so on, but no. Not without nesting partials another dozen deep. I came up with a simpler solution:

<style>
    .recommended .gh-feed article:nth-child(n+5) {
        display: none;
    } 
</style>

That little bit of styling causes any posts beyond four to not get rendered. It's a much better solution to my problem of always wanting four posts displayed. While I don't recommend that you load thousands of extra post cards, I'm willing to take the hit on up to eight.

So how bad is it, performance-wise?

Any related posts implementation is going to do an extra #get request, which means another database query. I looked at server response times (just on my local development setup) to get some sense of the cost.

With no "read more" or "related posts" block, the server wait times (across five runs) clustered around 70ms. When I added a simple 'related posts' block like the one from Ruby, my server wait time went to 110ms. And when I added in the fancy related posts setup above, it sent to 140ms. So a better related posts block does cost a bit in terms of server response time. (Although the performance of my laptop running sqlite3 is likely only somewhat similar to an actual server running MySQL, and the cost of these queries is going to depend quite a bit on the size of the database...)

We have a content API! While I don't normally like pulling content from it on a normal Ghost page, the related posts section is going to be well down the page (unless you're micro-blogging) and "below the fold", so an extra call to the content API isn't going to cause any shifting around or jumping.

Here's some code I've used:

let taglist =[]
let threeposts = []
let postid = '{{id}}'
let nodupeslist=[]
let apistring = "PUT API URL HERE"
let apikey = "PUT CONTENT API KEY HERE"

    for (let el of ['{{tags.[0].slug}}','{{tags.[1].slug}}', '{{tags.[2].slug}}','{{tags.[3].slug}}','{{tags.[4].slug}}','{{tags.[5].slug}}','{{tags.[6].slug}}'] ) {
        if (el && el[0] != "#") {taglist.push(el)}
    }

async function getRelated() {
    let data = await fetch(`${apistring}ghost/api/content/tags/?key=${apikey}&filter=slug:[${taglist}]&include=count.posts&order=count.posts%20ASC`)
    let tags = await data.json()
    let filterstring=""
    for (let onetag of tags.tags) {
            if (nodupeslist.length > 0 ) {filterstring=`%2Btags:-[${nodupeslist}]`}
            if (threeposts.length < 3) {
                let posts = await fetch(`${apistring}ghost/api/content/posts/?key=${apikey}&filter=id:-[${postid}]%2Btags:[${onetag.slug}]${filterstring}&limit=3`)
                    let dataposts = await posts.json()
                    for (let onepost of dataposts.posts) {
                        if (threeposts.length < 3 ) {
                        threeposts.push(onepost)
                        nodupeslist.push(onetag.slug)
                        } 
                    }   
            }
        }
    drawPosts(threeposts); // theme specific solution that creates and inserts a 'post card' for each item.
}


getRelated()

The good thing about using JavaScript is that the extra database calls come after the original post page is built and returned. However, this solution means no 'related posts' block for any readers without JavaScript. (Do these people still exist? 🤷 )

Option #3: Use Algolia's "recommendations" module.

If you already have a search integration with Algolia, taking advantage of their recommendations service might make sense. I have one client with every single post tagged as 'post', and only a tiny handful of other tags in use on her huge (and very successful) blog. She didn't want to re-tag, and we'd already set up full-text search, so we added a recommended posts block that leveraged Algolia's full-content indexing. It went like this:

<script src="https://cdn.jsdelivr.net/npm/@algolia/recommend"></script>
<script>
  
  const algoliarecommend = window['@algolia/recommend'];
  const client = algoliarecommend('SITEID', 'ALGOLIAKEY');

client.getRecommendations([
  {
    indexName: 'INDEXNAME',
    objectID: '{{post.id}}_0',
    model: 'related-products',
    maxRecommendations: 3
  },
])
.then(({ results }) => {
  if (results[0]['hits'].length > 0) {
    document.getElementById('readNextHere').innerHTML = 'Read Next'
  }
  for (let onehit of results[0]['hits']) {
    let oneitem = document.createElement('article')
    oneitem.classList.add('gh-card')
    oneitem.classList.add('post-card')
    oneitem.innerHTML = `<a class="gh-card-link" href="/${onehit.slug}">

            <figure class="gh-card-image">
                <img src="${onehit.image}"
                    alt="${onehit.title}"
                >
            </figure>


        <div class="gh-card-wrapper">
            <header class="gh-card-header">
                <h3 class="gh-card-title">${onehit.title}</h3>
            </header>

                <div class="gh-card-excerpt" id="${onehit.slug}"></div>

        </div>
    </a>`

// this is where I discovered that we didn't actually have her excerpts in Algolia.  Oops!  
    document.getElementById('insertHere').appendChild(oneitem)
    fetch(`/ghost/api/content/posts/slug/${onehit.slug}/?key=CONTENTAPIKEY&fields=custom_excerpt`)
    .then(result => result.json())
    .then(data => document.getElementById(onehit.slug).innerHTML = data.posts[0]['custom_excerpt'])
  }
  
})
.catch(err => {
  console.log(err);
});

</script>

No more retagging required, and she gets great 'related content' without any tagging at all!


Hey, before you go... If your finances allow you to keep this tea-drinking ghost and the freelancer behind her supplied with our hot beverage of choice, we'd both appreciate it!