Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Search

Self-host documentation search with Typesense

A Typesense server you run, scoped keys for your build and your readers, a docs collection rebuilt on every production build, and synonyms relinked after each sync.

By 9 min read

To self-host search for a documentation site, run a Typesense server, give the site a search-only key that browsers can use, and let your build write the index with a separate key. In Blume, the typesense() search adapter handles both halves: every blume build rebuilds a Typesense collection from your pages, and the search dialog queries that collection straight from the reader's browser.

By the end of this guide, you have a local Typesense server you've tested with a search, a synonym, and a deleted page, then a production server behind HTTPS, synced only from production builds by a script that fails when the sync does.

You may not need a search service at all. Blume's default search runs in the browser with no server or keys, and Pagefind covers very large sites the same way; choosing your docs search compares the options. Typesense fits when you want the index on infrastructure you control, with synonyms and curation you manage. If you want Typesense without running a server, Typesense Cloud hosts it, and everything here except the server setup still applies.

How Blume syncs the collection

At the end of blume build, Blume turns each searchable page into one record: its title, description, and body as plain text, plus its URL, search.keywords, search.boost, locale, and docs version. It reads your Markdown and MDX source, not the built HTML. Then it deletes the collection, creates it again with its own schema, and imports the records. Recreating the collection has three effects this guide plans around:

  • A deleted or renamed page disappears from results on the next build, because nothing from the old collection survives.
  • Anything you attached to the collection yourself, like a synonym set, is detached. You reapply it after each sync.
  • Between the delete and the end of the import, searches fail or return only some pages. While the collection is missing, the dialog shows "Something went wrong. Please try again." The gap lasts as long as the import, so it grows with your docs.

How this differs from the DocSearch scraper

Typesense's docs describe another route, DocSearch: a scraper that crawls your live site. It solves the same problem from the other end:

Blume's syncDocSearch scraper
ReadsYour source files, during the buildYour deployed HTML, over HTTP
RunsInside blume buildA Docker container you run after each deploy
RecordsOne per pageOne per heading and paragraph, picked out by CSS selectors
ReindexingDeletes and recreates the collectionFills a new timestamped collection, then points an alias at it
Search UIBlume's search dialogThe typesense-docsearch.js library

The scraper's alias swap means a search never hits a missing collection, which Blume's sync can't promise. In exchange, Blume needs no crawler, no selectors to maintain, and no deployed site to index. The two don't mix: Blume's dialog queries the fields its own sync writes, so it can't read a collection the scraper filled.

Run Typesense locally

Start a Typesense 30.2 server with Docker, outside your docs project, with a throwaway admin key:

docker run -p 8108:8108 -v "$(pwd)"/typesense-data:/data \
  typesense/typesense:30.2 \
  --data-dir /data --api-key=local-admin-key --enable-cors

In another terminal, check that it's up:

curl http://localhost:8108/health

It answers {"ok":true} once it's ready.

Create a sync key and a search-only key

The key you started the server with can do anything, so keep it away from your build and your readers. Create two narrower keys, both limited to a collection named docs. The sync key can manage that collection and its documents, which is all Blume's sync needs:

curl -s "http://localhost:8108/keys" -X POST \
  -H "X-TYPESENSE-API-KEY: local-admin-key" \
  -H "Content-Type: application/json" \
  -d '{"description": "Docs sync", "actions": ["collections:*", "documents:*"], "collections": ["docs"], "value": "local-sync-key"}'

The search key can only search it:

curl -s "http://localhost:8108/keys" -X POST \
  -H "X-TYPESENSE-API-KEY: local-admin-key" \
  -H "Content-Type: application/json" \
  -d '{"description": "Docs search", "actions": ["documents:search"], "collections": ["docs"], "value": "docs-search-public"}'

Typesense generates a random key when you leave out value. Here you set both values so the local steps stay copyable. The search key ships to every reader's browser, so it doesn't need to be secret; its scope is what protects you. Try to delete the collection with it, and Typesense answers Forbidden:

curl -s -X DELETE "http://localhost:8108/collections/docs" \
  -H "X-TYPESENSE-API-KEY: docs-search-public"

Point Blume at the collection

The Typesense client is an optional dependency of Blume, so install it in your docs project:

npm install typesense@3.1.0

Then switch search to Typesense:

import { defineConfig } from "blume";
import { typesense } from "blume/search";

export default defineConfig({
  // ...the rest of your config
  search: typesense({
    host: "localhost",
    port: 8108,
    protocol: "http",
    collection: "docs",
    apiKey: "docs-search-public",
  }),
});

port and protocol default to 443 and https, so the local server needs both. Blume reads the sync key from TYPESENSE_ADMIN_API_KEY, which never enters the config or the browser. Despite the name, it doesn't need Typesense's admin key: the scoped sync key is enough. Blume loads .env.local for every command, so put it there and keep the file out of Git:

echo "TYPESENSE_ADMIN_API_KEY=local-sync-key" >> .env.local
echo ".env.local" >> .gitignore

Last, add a throwaway page with a word no other page uses, and a keyword that appears nowhere in its text:

---
title: Search test
description: A throwaway page for checking the Typesense sync.
search:
  keywords: [canary]
---

The quasar client is a word no other page uses.

Build and sync

npx blume build

Once the pages are built, the log reports the sync with a line like Synced 12 record(s) to typesense, one record per searchable page. If it says Search sync skipped instead, the build still succeeded but the collection didn't change; see Troubleshooting.

Test searches

Define a shell function that searches the way Blume's dialog does, with the same fields and ranking, and prints the matching URLs:

search() {
  curl -s -G "http://localhost:8108/collections/docs/documents/search" \
    -H "X-TYPESENSE-API-KEY: docs-search-public" \
    --data-urlencode "q=$1" \
    --data-urlencode "query_by=title,keywords,description,content" \
    --data-urlencode "sort_by=_text_match(buckets: 10):desc,boost:desc" \
    | jq -c '{found, urls: [.hits[].document.url]}'
}

search "quasar"
search "canary"

Both print {"found":1,"urls":["/search-test"]}: the first matches the body text, the second the page's keywords. Now try the dialog itself. Run npx blume dev, open the site, press ⌘K (or Ctrl K), and search for quasar.

The dialog queries your Typesense server directly, in dev as in production. But blume dev never syncs, so it searches the collection your last build wrote. Edits you make in dev reach search on the next build.

Reapply custom settings after each sync

Typesense 30 keeps synonyms in synonym sets, which live outside any collection and are linked to one by name. Create a set that makes pulsar find the same pages as quasar, then link it to docs:

curl -s "http://localhost:8108/synonym_sets/docs-synonyms" -X PUT \
  -H "X-TYPESENSE-API-KEY: local-admin-key" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"id": "client-names", "synonyms": ["quasar", "pulsar"]}]}'

curl -s "http://localhost:8108/collections/docs" -X PATCH \
  -H "X-TYPESENSE-API-KEY: local-sync-key" \
  -H "Content-Type: application/json" \
  -d '{"synonym_sets": ["docs-synonyms"]}'

Now search "pulsar" finds the test page. Synonyms match whole words, so list every form you need, like both webhook and webhooks.

Run npx blume build again and repeat the search: it finds nothing. The set still exists, but the recreated collection isn't linked to it. Run the PATCH again and the synonym is back. Curation sets, which pin or hide results for a query, detach the same way and relink through the curation_sets field. The build script in the deploy step relinks after every production sync.

Test a deleted page

Delete the test page, build again, and search for it:

rm docs/search-test.mdx
npx blume build
search "quasar"
search "canary"

Both print {"found":0,"urls":[]}. A sync that only added and updated records would keep the old one, and a reader who clicked it would land on a 404.

Deploy

Run Typesense on a server

Your docs are served over HTTPS, so browsers block search requests to a plain HTTP server. On a server with Docker, and a DNS record for search.acme.example pointing at it, run Typesense behind Caddy, which gets a certificate for the domain on its own:

services:
  typesense:
    image: typesense/typesense:30.2
    restart: unless-stopped
    volumes:
      - ./typesense-data:/data
    command: "--data-dir /data --api-key=${TYPESENSE_API_KEY} --enable-cors --cors-domains=https://docs.acme.example,http://localhost:4321"

  caddy:
    image: caddy:2.11.4
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:
search.acme.example {
	reverse_proxy typesense:8108
}

--cors-domains lists the sites whose pages may search from a browser: your docs, and blume dev on your machine. It isn't access control, since anything outside a browser can still use the search key. Generate the admin key into .env, where Docker Compose reads it, and start both containers:

mkdir -p typesense-data
echo "TYPESENSE_API_KEY=$(openssl rand -hex 32)" > .env
docker compose up -d
curl https://search.acme.example/health

Create the production keys

On the server, in the same folder, create the same two keys. Leave out value on the sync key so Typesense generates a random one:

. ./.env

curl -s "https://search.acme.example/keys" -X POST \
  -H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "Docs sync", "actions": ["collections:*", "documents:*"], "collections": ["docs"]}'

curl -s "https://search.acme.example/keys" -X POST \
  -H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "Docs search", "actions": ["documents:search"], "collections": ["docs"], "value": "docs-search-public"}'

Typesense shows a generated key only once, as value in the response. Save the sync key's value in your host's environment as TYPESENSE_ADMIN_API_KEY, set for production builds only. A preview build that can see the key syncs too, and replaces production's collection with that branch's pages. Create the synonym set on this server with the same PUT as before, using this admin key.

Update the config

Point the adapter at the production server. With HTTPS on port 443, port and protocol can go:

search: typesense({
  host: "search.acme.example",
  collection: "docs",
  apiKey: "docs-search-public",
}),

Remove the TYPESENSE_ADMIN_API_KEY line from .env.local too. Local builds then skip the sync with a warning instead of trying the local key against production.

Fail the build when the sync fails

A failed sync doesn't fail blume build. It logs a warning and the build carries on, so a stale or empty index can ship unnoticed. This script fails on that warning and relinks the synonym set after a sync:

#!/usr/bin/env bash
# Builds the docs. On builds that sync search, fails if the sync didn't
# finish, then relinks the synonym set the sync detached.
set -euo pipefail

log="$(mktemp)"
npx blume build 2>&1 | tee "$log"

if [ -z "${TYPESENSE_ADMIN_API_KEY:-}" ]; then
  exit 0
fi

if grep -q "Search sync skipped" "$log"; then
  echo "The Typesense sync failed. Search may be stale or empty until a sync succeeds." >&2
  exit 1
fi

curl -fsS -X PATCH "https://search.acme.example/collections/docs" \
  -H "X-TYPESENSE-API-KEY: $TYPESENSE_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"synonym_sets": ["docs-synonyms"]}'

Make it your build script, so your host runs it:

{
  "scripts": {
    "dev": "blume dev",
    "build": "bash scripts/build-docs.sh"
  }
}

On builds without the key, like previews, the script only builds. The relink fails the build if the synonym set doesn't exist on the server, so create it first, or delete the curl step if you don't use synonyms.

The sync runs at the end of the build, before your host publishes the new pages. For the length of a deploy, search can list a page the live site doesn't serve yet.

Troubleshooting

The log says TYPESENSE_ADMIN_API_KEY is not set

The build ran without the sync key, so the collection didn't change. On your host, check the variable is set for the environment that built. Locally, Blume reads .env.local from the folder you build in and its parents, up to the repository root.

The log says the request failed with HTTP code 401

Typesense rejected the sync key. It's usually the search key in TYPESENSE_ADMIN_API_KEY, or a sync key scoped to a different collection than the config's collection. The rejection comes before the delete, so the old collection keeps serving.

The build fails with BLUME_DEPENDENCY_MISSING

The typesense package isn't installed where your project can resolve it. The error's suggestion has the install command for your package manager.

The dialog says "Something went wrong. Please try again."

The browser's request to Typesense failed. Find it in the browser's network panel:

  • Blocked by CORS: add the site's origin to --cors-domains, exactly as the address bar shows it, with no trailing slash.
  • Blocked as mixed content: the site is on HTTPS and the config points at an http server. Serve Typesense over HTTPS.
  • 404, Collection not found: a sync deleted the collection and didn't finish, or one is running now. Build again with the key set.
  • 401: the config's apiKey isn't a search key for this collection on this server.

Synonyms stopped working after a deploy

A sync ran without the relink, usually a build that didn't go through scripts/build-docs.sh. Run the PATCH by hand, then check your host's build command.

You're moving from a DocSearch collection

Stop the scraper and give Blume a new collection name. The name the scraper gave you is an alias: pointed at it, Blume's first sync deletes the collection behind the alias and creates a real collection under the same name, leaving the stale alias behind.

Next step

Find what readers can't find

Record searches that return nothing, then write or retag the pages they point to.

Read the search analytics guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init