Right, so you’ve decided to build a search function for your Hugo site. Good for you. It’s the single best feature you can add to a static site that’s grown beyond a handful of pages. But Hugo, in its infinite wisdom, doesn’t ship with a built-in search. It gives you the ingredients—your content—and expects you to bake the cake yourself. The first and most crucial step is creating the index, a JSON file that our search libraries can actually understand. Think of it as the map to your treasure trove of content. No map, no treasure.

We’re going to use Hugo’s powerful templating engine to generate this map for us. It’s a bit meta: we’re writing a template that outputs a JSON file, which is itself a template of your site’s content. Don’t think about that too long or you’ll get a headache.

The Foundation: Your searchindex.json Layout

First, you need to tell Hugo to generate a JSON file. You do this by creating a layout specifically for it. In your layouts directory, create a file named searchindex.json. The name is arbitrary, but searchindex.json is clear and sensible.

The magic starts with defining the output format. Hugo is built around rendering HTML, so we have to explicitly tell it, “Hey, for this specific thing, I want JSON.” We do this in your site’s config.toml (or yaml/json):

[outputs]
home = ["HTML", "RSS", "JSON"]

This line is absolutely critical. It tells Hugo, “When you’re rendering the home page (which is what our index will use as its basis), please output these formats.” Without "JSON" in that list, Hugo will politely ignore your searchindex.json layout. It’s the most common “why isn’t this working?!” moment, so check this first.

Now, let’s look at the basic structure of the searchindex.json file itself.

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
  {{- $.Scratch.Add "index" (dict "title" .Title "permalink" .Permalink "content" .Plain) -}}
{{- end -}}
{{- ($.Scratch.Get "index") | jsonify -}}

This is the “Hello World” of search indices. It loops through all your regular pages (so no section pages, taxonomies, etc.), and for each one, it creates a tiny dictionary with the title, the permalink, and the plain-text content. Then, the jsonify function takes that big slice of dictionaries and turns it into a beautifully formatted JSON array. But we can do much, much better.

Crafting a Useful Index Entry

The naive example above is functional, but it’s a bit…dumb. .Plain gives you everything, including the kitchen sink: code blocks, nav text, footer content, the whole shebang. This will lead to noisy search results. Let’s get surgical.

{
  "title": {{ .Title | jsonify }},
  "permalink": {{ .Permalink | jsonify }},
  {{/* A summary is gold for result snippets */}}
  "summary": {{ .Summary | plainify | jsonify }},
  {{/* Target specific sections. `.Params.tags` is an array, so it's perfect for JSON */}}
  "tags": {{ .Params.tags | jsonify }},
  {{/* Let's create a custom, cleaned-up content field */}}
  "content": {{ .Plain | replaceRE "(?s)<pre.*?</pre>" " " | replaceRE "\\s+" " " | jsonify }}
}

Now we’re talking. We’re grabbing a clean summary, the tags for filtering, and for the main content, we’re doing something cleverly evil. That gnarly regex (?s)<pre.*?</pre> matches every single code block in your content (the (?s) makes the dot match newlines). We replace them with a single space. Why? Because you don’t want a search for “const” returning every single JavaScript tutorial page you’ve ever written based on the code samples. This drastically reduces noise and focuses the search on your actual prose.

Handling Edge Cases and Performance

What about pages with no tags? Hugo’s jsonify is smart enough to output null for an empty array, which is fine. But what if you have thousands of pages? Generating this index on every build could get slow.

This is where Hugo’s partial caching can be a lifesaver. If you have a massive site, consider breaking the index building into a partial that’s cached.

{{- $pages := .Site.RegularPages -}}
{{- $index := slice -}}
{{- range $pages -}}
  {{- $entry := dict "title" .Title "permalink" .Permalink -}}
  {{- $index = $index | append $entry -}}
{{- end -}}
{{- $index | jsonify -}}

By storing the pages in a variable first and avoiding the Scratch, you give Hugo’s compiler more opportunities to optimize. For most sites, it’s negligible, but for behemoths, it’s a good practice.

The final piece of advice? Open your generated /index.json file in the browser. Look at it. Is the JSON valid? Are the fields you expect there? Is there a ton of garbage data in the content field? This isn’t a “set and forget” operation. Tweak your template, rebuild, and inspect. Your search is only as good as the index it’s running on, and you are its architect. Now build a good one.