One Fuse.js index can search five different content types at once
TIL that a single flat Fuse.js index with weighted keys is enough to search blog posts, TIL entries, projects, case studies, and snippets together — no separate index or backend needed per collection.
Today I learned that site search across multiple, differently-shaped content collections doesn't need one index per collection — a single flat array with a shared shape and weighted keys is enough for Fuse.js to rank results sensibly across all of them.
This site has five collections (blog, til, projects, case-studies, snippets), each with its own frontmatter fields. Building the index means mapping every item down to one common SearchDoc shape first:
export const getSearchIndex = cache(function getSearchIndex(): SearchDoc[] {
return CONTENT_COLLECTIONS.flatMap((collection) =>
getContentSlugs(collection)
.map((slug) => getContentBySlug(collection, slug))
.filter((i): i is ContentItem => i !== null)
.filter((i) => shouldInclude(i, false))
.map((item) => ({
slug: item.slug,
collection: item.collection,
url: item.url,
title: item.frontmatter.title,
tags: item.frontmatter.tags ?? [],
excerpt: toExcerpt(item.content), // stripped, capped plain text
}))
);
});The excerpt field is what makes full-text search possible without shipping every article's raw Markdown to the client — it's the body with code fences and Markdown syntax stripped, capped at 4000 characters. Ranking quality then comes entirely from Fuse's per-key weight, not from separate indices:
const FUSE_OPTIONS = {
keys: [
{ name: "title", weight: 4 },
{ name: "tags", weight: 3 },
{ name: "description", weight: 2 },
{ name: "category", weight: 2 },
{ name: "excerpt", weight: 1 },
],
threshold: 0.32,
ignoreLocation: true,
};A title match on a snippet outranks an excerpt match buried in a case study, even though both live in the same flat array — the collection field is just along for the ride, used to render a badge and build the right URL, not to partition the search.
The index is fetched lazily — only when the search modal actually opens (fetch("/search-index.json")), not on every page load — so cross-collection search costs nothing until someone hits ⌘K. Fuse itself is only constructed client-side, inside a useMemo keyed on the fetched docs.
Related content
A JSX expression-container attribute silently loses its value in next-mdx-remote
TIL that generating attr={"..."} into MDX source instead of attr="..." made a prop come back as an empty object on every render, with no error anywhere in the pipeline — found while writing the Obsidian-callout preprocessor for this site.
Clonify.io: Turning a Slow WordPress Marketplace into a High-Performance Next.js SaaS Platform
Rebuilding a digital-products marketplace from WordPress to Next.js and Payload CMS, with a custom JSON caching layer and secure, subscription-aware asset delivery.
TeamEnoch.com: Building a High-Performance Business Website with Next.js and a Headless CMS
Turning a traditional service-business website into an SEO-focused, content-driven Next.js and Payload CMS platform built to generate leads and scale with the business.