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 Hayden Bleasel9 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 sync | DocSearch scraper | |
|---|---|---|
| Reads | Your source files, during the build | Your deployed HTML, over HTTP |
| Runs | Inside blume build | A Docker container you run after each deploy |
| Records | One per page | One per heading and paragraph, picked out by CSS selectors |
| Reindexing | Deletes and recreates the collection | Fills a new timestamped collection, then points an alias at it |
| Search UI | Blume's search dialog | The 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-corsIn another terminal, check that it's up:
curl http://localhost:8108/healthIt 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.0Then 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" >> .gitignoreLast, 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 buildOnce 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/healthCreate 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
httpserver. 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
apiKeyisn'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 guideA step here not working for you? Report a broken step.