Lawtee Blog

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---
2title: "Search"
3date: 2025-11-20T00:00:00+08:00
4type: "search"
5layout: "search"
6---
7Search 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[outputs]
2  home = ["HTML", "RSS", "JSON"]  # Add JSON output
3
4[outputFormats.JSON]
5  baseName = "index"
6  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{{ define "main" }}
  2<content>
  3  <h1>{{ .Title }}</h1>
  4  <div class="search-box">
  5    <input
  6      id="search-input"
  7      class="search-input"
  8      type="text"
  9      placeholder="Enter keywords to search…"
 10      autocomplete="off"
 11    />
 12  </div>
 13
 14  <ul id="results" class="search-results">
 15    <li style="color:#666">Please enter keywords to search</li>
 16  </ul>
 17
 18</content>
 19
 20<script>
 21// ========== Utility Functions ==========
 22function escapeHtml(str) {
 23  if (!str) return "";
 24  return str
 25    .replace(/&/g, "&amp;")
 26    .replace(/</g, "&lt;")
 27    .replace(/>/g, "&gt;");
 28}
 29
 30// ========== Exact Match Search ==========
 31function exactMatch(haystack, needle) {
 32  if (!haystack || !needle) return false;
 33  return haystack.toLowerCase().includes(needle.toLowerCase());
 34}
 35
 36// ========== Render Results ==========
 37function renderResults(list) {
 38  const resultsEl = document.getElementById("results");
 39
 40  if (!list || list.length === 0) {
 41    resultsEl.innerHTML = '<li style="color:#666">No results found.</li>';
 42    return;
 43  }
 44
 45  const itemsHtml = list.map(item => {
 46    const title = escapeHtml(item.title || "(No Title)");
 47
 48    // External or local links
 49    const url = escapeHtml(item.link || item.url || "#");
 50    const isExternal = !!item.link;
 51
 52    const linkAttrs = isExternal
 53      ? ' target="_blank" rel="noopener noreferrer"'
 54      : "";
 55
 56    // Summary: take summary or the beginning of content
 57    const summaryRaw =
 58      item.summary ||
 59      (item.content ? item.content.slice(0, 200) + "…" : "");
 60
 61    const summary = escapeHtml(summaryRaw);
 62
 63    return `
 64      <li>
 65        <div class="sr-title-col">
 66          <a class="sr-title" href="${url}"${linkAttrs}>${title}</a>
 67        </div>
 68
 69        <div class="sr-snippet-col">
 70          <div class="sr-snippet">${summary}</div>
 71        </div>
 72      </li>
 73    `;
 74  }).join("");
 75
 76  resultsEl.innerHTML = itemsHtml;
 77}
 78
 79// ========== Load index.json Data ==========
 80async function loadIndex() {
 81  try {
 82    const res = await fetch("/index.json");
 83    return await res.json();
 84  } catch (err) {
 85    console.error("Failed to load index.json:", err);
 86    return [];
 87  }
 88}
 89
 90// ========== Main Logic ==========
 91(async function () {
 92  const data = await loadIndex();
 93  const input = document.getElementById("search-input");
 94
 95  input.addEventListener("input", () => {
 96    const q = input.value.trim();
 97
 98    if (!q) {
 99      renderResults([]);
100      return;
101    }
102
103    // Exact search: match title / content / tags
104    const result = data.filter(item =>
105      exactMatch(item.title, q) ||
106      exactMatch(item.content, q) ||
107      (item.tags || []).some(t => exactMatch(t, q))
108    );
109
110    renderResults(result);
111  });
112})();
113</script>
114{{ 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{{- $pages := where .Site.RegularPages "Type" "not in" (slice "page" "something-you-want-to-exclude") -}}
 3{{- $first := true -}}
 4{{- range $i, $p := $pages -}}
 5  {{- if not $first }},{{ end -}}
 6  {
 7    "title": {{ $p.Title | jsonify }},
 8    "url": {{ $p.RelPermalink | absURL | jsonify }},
 9    "date": {{ $p.Date.Format "2006-01-02" | jsonify }},
10    "summary": {{ with $p.Params.description }}{{ . | jsonify }}{{ else }}{{ $p.Summary | plainify | jsonify }}{{ end }},
11    "content": {{ $p.Plain | chomp | jsonify }},
12    "tags": {{ $p.Params.tags | jsonify }},
13    "categories": {{ $p.Params.categories | jsonify }}
14  }
15  {{- $first = false -}}
16{{- end -}}
17]

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/* ====== Search Box Layout ====== */
  2.search-box {
  3  max-width: 720px;
  4  margin: 24px 0 32px;
  5}
  6
  7.search-input {
  8  width: 100%;
  9  padding: 10px 14px;
 10  font-size: 16px;
 11
 12  border: 1px solid var(--border);
 13  border-radius: 8px;
 14
 15  background: var(--entry);
 16  color: var(--primary);
 17
 18  outline: none;
 19  transition: border-color .15s ease, box-shadow .15s ease;
 20}
 21
 22.search-box {
 23  max-width: 720px;
 24  margin: 24px 0 32px;
 25}
 26
 27.search-input {
 28  width: 100%;
 29  padding: 10px 14px;
 30  font-size: 16px;
 31
 32  border: 1px solid rgba(150, 150, 150, 0.35);
 33  border-radius: 8px;
 34
 35  background: var(--entry);
 36  color: var(--primary);
 37  outline: none;
 38
 39  transition: border-color .15s ease, box-shadow .15s ease;
 40}
 41
 42.search-input:focus {
 43  border-color: var(--text-highlight);
 44  box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.20);
 45}
 46
 47.search-results {
 48  list-style: none;
 49  padding: 0;
 50  margin: 0;
 51}
 52
 53.search-results li {
 54  display: flex;
 55  align-items: flex-start;
 56  gap: 16px;
 57  padding: 12px 0;
 58  border-bottom: 1px solid rgba(0,0,0,0.04);
 59}
 60
 61.search-results .sr-title-col {
 62  flex: 0 0 40%;
 63  min-width: 180px;
 64  max-width: 420px;
 65}
 66
 67.search-results .sr-title {
 68  font-size: 1.02rem;
 69  line-height: 1.3;
 70  text-decoration: none;
 71  color: var(--primary);
 72}
 73
 74.search-results .sr-title[target="_blank"]::after {
 75  content: " ↪";
 76  font-weight: 400;
 77}
 78
 79.search-results .sr-snippet-col {
 80  flex: 1 1 60%;
 81}
 82
 83.search-results .sr-snippet {
 84  color: var(--secondary);
 85  font-size: 0.95rem;
 86  line-height: 1.5;
 87
 88  overflow: hidden;
 89  display: -webkit-box;
 90  -webkit-line-clamp: 3;
 91  -webkit-box-orient: vertical;
 92}
 93
 94@media (max-width: 500px) {
 95  .search-results li {
 96    flex-direction: column;
 97    gap: 8px;
 98  }
 99
100  .search-results .sr-title-col {
101    max
102``````css
103-width: none;
104  }
105}

#hugo blog #search functionality

Comments