Contents

Add Related Posts to Articles in the LoveIt Theme

Introduction

Recently, while reading articles, I noticed that it’s quite troublesome to find similar posts after finishing one. While researching, I also saw that many websites have sections like “Related Posts” or “You May Also Like,” which inspired me to create a “Related Posts” section on my personal site.

Since it’s about related posts, we need a way to determine which ones are similar, right? But re-tagging all posts or going through the trouble of using a classification model seems exhausting (or just plain silly?). Then I realized my articles already use tags and categories. So I thought: “Why not make use of what’s already there?” That’s why I decided to use existing metadata for classification.

Below is a guide on how to add a “Related Posts” section to each article.

First, create a new file named related-carousel.html in /layouts/partials/single/ under your site’s root directory, and paste the following code into it.

You need to note that on line 4, you’ll need to provide a default image URL that will be used when an article has no featured image.

In my previous post Locking articles in Hugo, I added a feature to restrict article access with a password. Here, I don’t want related posts to include password-protected content, so line 15 filters those out.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
{{ $current := . }}
{{ $category := index .Params.categories 0 }}
{{ $tags := .Params.tags }}
{{ $defaultImg := "url of your image" | absURL }}  /* Alternative solution if image not found */

{{/* Get articles with the same categories or tags */}}
{{ $relatedByCategory := where .Site.RegularPages "Params.categories" "intersect" (slice $category) }}
{{ $relatedByTags := where .Site.RegularPages "Params.tags" "intersect" $tags }}

{{/* Merge the two and remove duplicates, excluding the current page */}}
{{ $combined := union $relatedByCategory $relatedByTags }}
{{ $related := where $combined "Permalink" "ne" .Permalink }}

{{/* Filter by condition: password is empty and the draft / hidden flag is false or none */}}
{{ $filtered := where $related "Params.password" "in" (slice "" nil) }}
{{ $filtered = where $filtered "Draft" false }}
{{ $filtered = where $filtered "Params.draft" false }}
{{ $filtered = where $filtered "Params.hiddenFromHomePage" false }}
{{ $filtered = where $filtered "Params.hiddenFromSearch" false }}

{{/* Number of  articles you want to show */}}
{{ $n := 10 }}

{{/* Sorted by date */}}
{{ $sorted := sort $filtered "Date" "desc" }}

{{ $latest := first $n $sorted }}

{{ if gt (len $latest) 0 }}

<section class="related-carousel-container" id="related-posts">
<h3 class="related-carousel-title">
  <a href="#related-posts" title="{{ T "relatedPosts" }}">{{ T "relatedPosts" }}</a>
</h3>
  <div class="carousel-wrapper">
    <button class="carousel-button prev"></button>
    <div class="carousel" id="related-carousel">
      {{ range $page := $latest }}
        <a class="carousel-card" href="{{ $page.RelPermalink }}">
            <img
            src={{ $defaultImg }}
            data-src="
            {{- with $page.Params.featuredImage -}}
                {{- $image := . -}}
                {{- if or (strings.HasPrefix $image "http") (strings.HasPrefix $image "https") -}}
                    {{- $image | safeURL -}}
                {{- else if (strings.HasPrefix $image "/") -}}
                    {{- $image | safeURL -}}
                {{- else -}}
                    {{- $filename := path.Base $image -}}
                    {{- with $page.Resources.Match (printf "**/%s" $filename) -}}
                        {{- (index . 0).RelPermalink -}}
                    {{- else -}}
                        {{- $defaultImg -}}
                    {{- end -}}
                {{- end -}}
            {{ else }}
                {{ $defaultImg }}
            {{ end }}"
            alt="{{ $page.Title }}"
            class="lazyload"
            >
            <div class="card-title">{{ $page.Title }}</div>
            <div class="card-date">{{ $page.Date.Format "2006-01-02" }}</div>
        </a>
        {{ end }}
    </div>
    <button class="carousel-button next"></button>
  </div>
</section>
{{ end }}

<script>
document.addEventListener('DOMContentLoaded', function () {
  const carousel = document.getElementById('related-carousel');
  const container = document.querySelector('.related-carousel-container');
  if (!carousel || !container) return;

  function updateJustify() {
    const cards = carousel.querySelectorAll('.carousel-card');
    let totalWidth = 0;

    cards.forEach(card => {
      const style = window.getComputedStyle(card);
      const marginRight = parseFloat(style.marginRight) || 0;
      totalWidth += card.offsetWidth + marginRight;
    });

    const containerWidth = container.clientWidth;

    if (totalWidth <= containerWidth) {
      carousel.classList.add('centered');
    } else {
      carousel.classList.remove('centered');
    }
  }

  updateJustify();
  window.addEventListener('resize', updateJustify);

  // Left and right button control
  const prevBtn = document.querySelector('.carousel-button.prev');
  const nextBtn = document.querySelector('.carousel-button.next');

  if (prevBtn && nextBtn) {
    prevBtn.addEventListener('click', () => {
      carousel.scrollBy({ left: -220, behavior: 'smooth' });
    });

    nextBtn.addEventListener('click', () => {
      carousel.scrollBy({ left: 220, behavior: 'smooth' });
    });
  }
});
</script>

Add CSS Styling

After implementing the post filtering logic, the next step is to style the carousel. To match the Hugo LoveIt theme, we’ll use white backgrounds and rounded corners, and also support dark mode.

Add the following styles to /assets/css/_custom.scss.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
// Related articles carousel
.related-carousel-title {
  margin-top: 0;
  margin-bottom: 0.5rem;
  padding: 0;
  //font-size: 1.5rem;
  font-weight: bold;
  color: $global-font-color;

  [theme=dark] & {
    color: $global-font-color-dark;
  }
}

.related-carousel-container {
  margin-top: 3rem;
  padding: 1rem;
  background: #f9f9f9;
  [theme=dark] & {
    background: #242424;
  }
}

.carousel-wrapper {
  position: relative;
  overflow: hidden;
}

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scroll-behavior: smooth;
  justify-content: flex-start;
  gap: 1rem;
}

.carousel.centered {
  justify-content: center;
}

.carousel::-webkit-scrollbar {
  display: none;
}

.carousel-card {
  flex: 0 0 auto;
  width: 200px;
  scroll-snap-align: start;
  text-decoration: none;
  color: inherit;
  background: white;
  border-radius: 5px;
  overflow: hidden;
  box-shadow: 0 2px 5px rgba(0,0,0,0.1);
  [theme=dark] & {
    background: #080808;
  }
}

.carousel-card img {
  width: 100%;
  height: 120px;
  object-fit: cover;
}

.card-title {
  font-size: 0.95rem;
  padding: 0.5rem;
  font-weight: bold;
}

.card-date {
  font-size: 0.8rem;
  padding: 0 0.5rem 0.5rem;
  color: gray;
}

.carousel-button {
  display: block;
  position: absolute;
  top: 40%;
  border: none;
  font-size: 2rem;
  padding: 0 0.5rem;
  cursor: pointer;
  z-index: 2;
  color: $global-font-color;
  background-color: rgba(255,255,255,0.8);

  &:hover {
    color: $global-link-hover-color;
  }

  [theme=dark] & {
    color: $global-font-color-dark;
    background: #000000;

    &:hover {
        color: $global-link-hover-color-dark;
    }
  }
}

.carousel-button.prev { left: 0; }
.carousel-button.next { right: 0; }
// Related articles carousel

Add Translation Key

To support multilingual sites, we need to add the following translation key.

In the /i18n/ folder, find the appropriate language file (e.g., en.toml) and add the following:

en.toml

1
2
3
4
# === custom at related carousel ===
[relatedPosts]
other = "Related Posts"
# === custom at related carousel ===

Finally, insert the new partial into your article template. Open /layouts/posts/single.html, locate the {{- /* Footer */ -}} section, and add the following below it:

1
2
3
4
5
        {{- /* Footer */ -}}
        {{- partial "single/footer.html" . -}}

        {{- /* Related Carousel */ -}}                    <!-- Added -->
        {{- partial "single/related-carousel.html" . -}}  <!-- Added -->

And that’s it! Your Related Posts section is now complete.

Conclusion

With the addition of the related posts section, readers can now find the next article they want to read more easily. It’s both user-friendly and visually appealing! Scroll down to check out the Related Posts section below 👇.

Environment

  • Hugo 0.145.0
  • LoveIt theme (GitHub version as of February 21, 2025)