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 Bleasel10 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 fills a new Typesense collection from your pages and points an alias at it, and the search dialog queries that alias 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 creates a new collection with its own schema, named after your collection setting plus a timestamp, like docs_1790792208477, and imports the records. Once the import is complete, it points an alias with the collection name at the new collection and drops the one the alias pointed at before. A fresh collection on every sync 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, doesn't carry over to the new one. You reapply it after each sync.
- Searches keep reading the previous collection until the new one is complete, and a sync that fails leaves the previous one serving. The sync key has to manage the alias and every collection named after it.
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 | Fills a new timestamped collection, then points an alias at it | The same |
| Search UI | Blume's search dialog | The typesense-docsearch.js library |
Both swap an alias, so a search never hits a missing collection. Blume needs no crawler, no selectors to maintain, and no deployed site to index, but its records are whole pages. 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. The sync key can manage the docs alias, the collections the sync names after it (docs_ and a timestamp, matched here by a regular expression), and their 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:*", "aliases:*"], "collections": ["docs", "docs_.*"], "value": "local-sync-key"}'The search key can only search docs, and reaches whichever collection the alias points at:
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. blume init lists the file in .gitignore; the second line adds it if your project doesn't:
echo "TYPESENSE_ADMIN_API_KEY=local-sync-key" >> .env.local
grep -qx ".env.local" .gitignore || echo ".env.local" >> .gitignoreIf your pages are in Chinese, Japanese, or Thai, also set locale to their two-letter language code, like locale: "ja". Typesense's default tokenizer splits text on spaces, so without it a word in those languages only matches at the start of a run of text. One collection holds every language, so the setting covers them all.
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 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. If the key is set and the sync fails, the build fails with BLUME_SEARCH_SYNC_FAILED. For both, 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: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 new collection behind the alias isn't linked to it. Run the PATCH again, which goes through the alias to that collection, and the synonym is back. Curation sets, which pin or hide results for a query, fall off 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:*", "aliases:*"], "collections": ["docs", "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 moves production's alias onto 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.
Relink the synonym set after each sync
With TYPESENSE_ADMIN_API_KEY set, a failed sync fails blume build with BLUME_SEARCH_SYNC_FAILED, and the alias keeps serving the last complete sync, so a stale index can't ship unnoticed. A sync that succeeds moves the alias to a new collection, which doesn't carry the synonym link over. This script relinks it after the build:
#!/usr/bin/env bash
# Builds the docs. On builds that sync search, links the synonym set to the
# new collection. A failed sync fails the build, so the script stops there.
set -euo pipefail
npx blume build
if [ -z "${TYPESENSE_ADMIN_API_KEY:-}" ]; then
exit 0
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 build fails with BLUME_SEARCH_SYNC_FAILED and HTTP code 401
The sync ran with a key and failed, and the message after Search sync to typesense failed: says why. With HTTP code 401, Typesense rejected the sync key. It's usually the search key in TYPESENSE_ADMIN_API_KEY, or a sync key whose scope misses part of what the sync touches: it needs the aliases:* action, and both the config's collection and that name followed by _.* in collections. The sync stops before it changes anything, so the last complete sync 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: no sync has finished on this server yet, so there's no alias, or the config's
collectionnames a different one. Build 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 first, or its next run moves the alias back. The name the scraper gave you is an alias, and Blume can take it over: pointed at it, Blume's first sync fills a new collection, moves the alias onto it, and drops the scraper's collection. Scope the sync key to that name and the name followed by _.*, as above.
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.