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.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

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:

src/lib/content.ts
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:

SearchClient.tsx
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.

Tip

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.