Featured image of post Adding Article Search Functionality to Hugo

Adding Article Search Functionality to Hugo

Previously, when using the Hugo-theme-stack theme, it came with built-in search functionality. I had assumed this was a default feature of Hugo, only to later realize it was an add-on.

After switching to the Bear cub theme, I didn’t initially think this feature was crucial. However, in practice, I found that sometimes I couldn’t recall certain details and needed search to assist. So, I looked into how to add search externally. It’s actually quite simple—just four steps.

Create a search.md Page in the content Directory

If you don’t need it in the navigation bar, just fill in default content. For navigation inclusion, follow the settings of other md files.

1
2
3
4
5
6
7
---
title: "Search"
date: 2025-11-20T00:00:00+08:00
type: "search"
layout: "search"
---
Search blog articles here.

Add JSON Output to the Hugo Configuration File

For example, if your Hugo configuration file is in TOML format, add the following.

1
2
3
4
5
6
[outputs]
  home = ["HTML", "RSS", "JSON"]  # Add JSON output

[outputFormats.JSON]
  baseName = "index"
  mediaType = "application/json"

Create a /search/single.html Template in the layouts Directory

You can modify the template content by referring to other templates. The search function can be customized for exact or fuzzy search. The general content is as follows.

  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
{{ define "main" }}
<content>
  <h1>{{ .Title }}</h1>
  <div class="search-box">
    <input
      id="search-input"
      class="search-input"
      type="text"
      placeholder="Enter keywords to search…"
      autocomplete="off"
    />
  </div>

  <ul id="results" class="search-results">
    <li style="color:#666">Please enter keywords to search</li>
  </ul>

</content>

<script>
// ========== Utility Functions ==========
function escapeHtml(str) {
  if (!str) return "";
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

// ========== Exact Match Search ==========
function exactMatch(haystack, needle) {
  if (!haystack || !needle) return false;
  return haystack.toLowerCase().includes(needle.toLowerCase());
}

// ========== Render Results ==========
function renderResults(list) {
  const resultsEl = document.getElementById("results");

  if (!list || list.length === 0) {
    resultsEl.innerHTML = '<li style="color:#666">No results found.</li>';
    return;
  }

  const itemsHtml = list.map(item => {
    const title = escapeHtml(item.title || "(No Title)");

    // External or local links
    const url = escapeHtml(item.link || item.url || "#");
    const isExternal = !!item.link;

    const linkAttrs = isExternal
      ? ' target="_blank" rel="noopener noreferrer"'
      : "";

    // Summary: take summary or the beginning of content
    const summaryRaw =
      item.summary ||
      (item.content ? item.content.slice(0, 200) + "…" : "");

    const summary = escapeHtml(summaryRaw);

    return `
      <li>
        <div class="sr-title-col">
          <a class="sr-title" href="${url}"${linkAttrs}>${title}</a>
        </div>

        <div class="sr-snippet-col">
          <div class="sr-snippet">${summary}</div>
        </div>
      </li>
    `;
  }).join("");

  resultsEl.innerHTML = itemsHtml;
}

// ========== Load index.json Data ==========
async function loadIndex() {
  try {
    const res = await fetch("/index.json");
    return await res.json();
  } catch (err) {
    console.error("Failed to load index.json:", err);
    return [];
  }
}

// ========== Main Logic ==========
(async function () {
  const data = await loadIndex();
  const input = document.getElementById("search-input");

  input.addEventListener("input", () => {
    const q = input.value.trim();

    if (!q) {
      renderResults([]);
      return;
    }

    // Exact search: match title / content / tags
    const result = data.filter(item =>
      exactMatch(item.title, q) ||
      exactMatch(item.content, q) ||
      (item.tags || []).some(t => exactMatch(t, q))
    );

    renderResults(result);
  });
})();
</script>
{{ end }}

Create an index.json Template in layouts

This mainly outputs the entire blog content to the index.json file for direct searching. You can modify it according to your needs, such as searching only titles, summaries, tags, etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[
{{- $pages := where .Site.RegularPages "Type" "not in" (slice "page" "something-you-want-to-exclude") -}}
{{- $first := true -}}
{{- range $i, $p := $pages -}}
  {{- if not $first }},{{ end -}}
  {
    "title": {{ $p.Title | jsonify }},
    "url": {{ $p.RelPermalink | absURL | jsonify }},
    "date": {{ $p.Date.Format "2006-01-02" | jsonify }},
    "summary": {{ with $p.Params.description }}{{ . | jsonify }}{{ else }}{{ $p.Summary | plainify | jsonify }}{{ end }},
    "content": {{ $p.Plain | chomp | jsonify }},
    "tags": {{ $p.Params.tags | jsonify }},
    "categories": {{ $p.Params.categories | jsonify }}
  }
  {{- $first = false -}}
{{- end -}}
]

Additional CSS Configuration

If you need to customize the CSS for the search page, you can add it directly to the theme CSS or a custom custom.css file, or include it in the /search/single.html template mentioned earlier.

  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
/* ====== Search Box Layout ====== */
.search-box {
  max-width: 720px;
  margin: 24px 0 32px;
}

.search-input {
  width: 100%;
  padding: 10px 14px;
  font-size: 16px;

  border: 1px solid var(--border);
  border-radius: 8px;

  background: var(--entry);
  color: var(--primary);

  outline: none;
  transition: border-color .15s ease, box-shadow .15s ease;
}

.search-box {
  max-width: 720px;
  margin: 24px 0 32px;
}

.search-input {
  width: 100%;
  padding: 10px 14px;
  font-size: 16px;

  border: 1px solid rgba(150, 150, 150, 0.35);
  border-radius: 8px;

  background: var(--entry);
  color: var(--primary);
  outline: none;

  transition: border-color .15s ease, box-shadow .15s ease;
}

.search-input:focus {
  border-color: var(--text-highlight);
  box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.20);
}

.search-results {
  list-style: none;
  padding: 0;
  margin: 0;
}

.search-results li {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  padding: 12px 0;
  border-bottom: 1px solid rgba(0,0,0,0.04);
}

.search-results .sr-title-col {
  flex: 0 0 40%;
  min-width: 180px;
  max-width: 420px;
}

.search-results .sr-title {
  font-size: 1.02rem;
  line-height: 1.3;
  text-decoration: none;
  color: var(--primary);
}

.search-results .sr-title[target="_blank"]::after {
  content: " ↪";
  font-weight: 400;
}

.search-results .sr-snippet-col {
  flex: 1 1 60%;
}

.search-results .sr-snippet {
  color: var(--secondary);
  font-size: 0.95rem;
  line-height: 1.5;

  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
}

@media (max-width: 500px) {
  .search-results li {
    flex-direction: column;
    gap: 8px;
  }

  .search-results .sr-title-col {
    max
``````css
-width: none;
  }
}
Built with Hugo, Powered by Github.
Total Posts: 385, Total Words: 565004.