Smarter Related Posts with JSON + JavaScript

For larger Jekyll sites or when you want fuzzy matching (e.g., shared keywords in titles or content), Liquid has limitations. This is where a JavaScript-based related post system becomes powerful. It loads a pre-built posts.json file and runs similarity matching in the browser, offering great flexibility without plugins or server-side logic.

1. Export Your Posts into a JSON File

Create a file like related.json or all-posts.json inside your _site directory using a custom layout that Jekyll will render. Here's how:


# _pages/posts.json
---
layout: null
permalink: /posts.json
---

[
  {% for post in site.posts %}
    {
      "title": {{ post.title | jsonify }},
      "url": "{{ post.url }}",
      "tags": {{ post.tags | jsonify }},
      "categories": {{ post.categories | jsonify }},
      "excerpt": {{ post.excerpt | strip_html | truncate: 150 | jsonify }},
      "content": {{ post.content | strip_html | jsonify }}
    }{% unless forloop.last %},{% endunless %}
  {% endfor %}
]

Save this with front matter so Jekyll renders it. After site build, it will be accessible at /posts.json.

2. Load JSON and Compute Matches on Client Side

In your post layout, include a small JavaScript snippet that fetches the JSON, then compares tags or content similarity with the current post.

<script>
document.addEventListener("DOMContentLoaded", function () {
  const currentTitle = document.querySelector("meta[property='og:title']")?.content || document.title;
  const currentTags = JSON.parse(document.querySelector("meta[name='post-tags']")?.content || "[]");

  fetch("/posts.json")
    .then((res) => res.json())
    .then((posts) => {
      const related = posts
        .filter(p => p.title !== currentTitle)
        .map(p => {
          let score = 0;
          currentTags.forEach(tag => {
            if (p.tags.includes(tag)) score += 1;
          });
          if (p.title.includes(currentTitle.split(" ")[0])) score += 1;
          return { ...p, score };
        })
        .filter(p => p.score > 0)
        .sort((a, b) => b.score - a.score)
        .slice(0, 5);

      const container = document.getElementById("related-posts");
      if (container && related.length) {
        container.innerHTML = `
          <h3>Related Posts</h3>
          <ul>
            ${related.map(post => `<li><a href="${post.url}">${post.title}</a></li>`).join("")}
          </ul>
        `;
      }
    });
});
</script>

And in your HTML, define the container:

<div id="related-posts"></div>

3. Embed Tags as Meta Tags for Runtime Access

Add this inside your HTML <head> section of the post layout:

{% raw %}


{% endraw %}

Advantages of the JSON + JS Method

  • Scalable: No Liquid performance hit.
  • Smarter logic: You can implement fuzzy, weight-based matching.
  • Realtime: Easy to update without rebuilding entire site.
  • Flexible rendering: You can add thumbnails, excerpts, or even dynamic filters.

Optional: Preprocess Related Posts Offline

For advanced control, you can generate related post relationships during build time using Python, Node.js, or Go. These scripts analyze your markdown files, compute similarity, and write them into a JSON like this:


{
  "/post-a/": ["/post-b/", "/post-c/", "/post-d/"],
  "/post-x/": ["/post-y/", "/post-z/"]
}

You can then load this in JavaScript, match against location.pathname, and render the list with pure frontend code.

Conclusion

By combining Jekyll's static generation with a lightweight client-side JS engine, you get the best of both worlds: fast, scalable related content without needing plugins or third-party APIs. This approach gives full control over logic, weight scoring, and styling—all while staying within the GitHub Pages framework.

In the next tutorial, we’ll explore integrating this related-posts engine with Lunr.js or FlexSearch so it can work together with site search, building an intelligent, full-text-aware content discovery system for static blogs.