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, "&")
26 .replace(/</g, "<")
27 .replace(/>/g, ">");
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