Integrate Headband
One path per kind of app, from an empty project to search that works. Each path is a working app in the headband-examples repo: clone it, fill in a .env, and run it.
- Next.js: the React SDK in the browser, with a seed script on the server.
- A plain JS page:
fetch()from the browser, no build step. - A Node backend and a Python backend: the whole REST flow, plus a search route of your own.
- Database sync with Drizzle: keep an index in step with your Postgres tables.
Before You Start
- Sign in at headband.dev and open a project.
- On the project's Keys tab, use Create API key twice: once for an admin key, once for a search key. Copy each key when it appears, because it is not shown again. Every path below uses both.
| Key | Prefix | Where it may live | What it can do |
|---|---|---|---|
| Admin key | hb_adm_ | Your server, scripts and CI only | Create and delete indexes, write documents, change settings, read stats and tasks. |
| Search key | hb_src_ | Anywhere, including browser code | Search, multi-search, list indexes, and send analytics and experiment events. Nothing else. |
The API answers CORS for any origin, so a page on your own domain (or localhost) can call it with the search key. A search key in page code can only search your project's indexes.
Every request goes to https://headband.dev/v1/... with the key in Authorization: Bearer <key>. Search, index and document answers are plain JSON objects, not wrapped in data. On a Meilisearch index a write answers 202 with a taskUid: poll GET /v1/tasks/{taskUid}?index=<uid> until it says succeeded before you search for what you wrote. Typesense, Elasticsearch and Visor apply a write before they answer.
Next.js
A product search page with facets, sorting and highlighting, built on @headband/react 0.1.1 (the version on npm) in a Next.js 16 App Router app. The browser holds only the search key. A seed script, run on your machine with the admin key, creates and fills the index.
git clone https://github.com/ZVN-DEV/headband-examples.git
cd headband-examples/nextjs
npm install
cp .env.example .env.local # fill in the keys
npm run seed # creates the index and adds 20 products
npm run dev # http://localhost:3000# Inlined into the browser bundle: a SEARCH key only.
NEXT_PUBLIC_HEADBAND_URL=https://headband.dev
NEXT_PUBLIC_HEADBAND_KEY=hb_src_...
NEXT_PUBLIC_HEADBAND_INDEX=products
# Server-only, read by scripts/seed.mjs. Never prefix an admin key with NEXT_PUBLIC_.
HEADBAND_ADMIN_KEY=hb_adm_...The seed script creates the index, sets which fields are searched, filtered and sorted, then adds the documents. With an admin key, on your server:
await hb("POST", "/v1/indexes", { uid: INDEX, primaryKey: "id" });
await hb("PATCH", `/v1/indexes/${INDEX}/settings`, {
searchableAttributes: ["title", "brand", "category"],
filterableAttributes: ["category", "brand", "price"],
sortableAttributes: ["price"],
});
const added = await hb("POST", `/v1/documents?index=${INDEX}&primaryKey=id`, products);
await waitForTask(added.taskUid); // Meilisearch: poll /v1/tasks until "succeeded"The page is a client component around the SDK's provider:
"use client";
import {
headband, HeadbandProvider, Highlight, Hits, type Hit,
Pagination, RefinementList, SearchBox, SortBy, Stats,
} from "@headband/react";
const client = headband(
process.env.NEXT_PUBLIC_HEADBAND_URL ?? "https://headband.dev",
process.env.NEXT_PUBLIC_HEADBAND_KEY! // hb_src_...
);
type Product = { id: number; title: string; brand: string; category: string; price: number };
function ProductHit({ hit }: { hit: Hit<Product> }) {
const product = hit.__data; // the document, typed as Product
return (
<article>
<Highlight hit={hit} attribute="title" />
<div>{product.brand} · {product.category} · ${product.price}</div>
</article>
);
}
export function ProductSearch() {
return (
<HeadbandProvider client={client} index={process.env.NEXT_PUBLIC_HEADBAND_INDEX ?? "products"}>
<SearchBox placeholder="Search products" />
<Stats />
<RefinementList attribute="category" header="Category" />
<RefinementList attribute="brand" header="Brand" />
<SortBy items={[
{ label: "Price, low to high", value: "price:asc" },
{ label: "Price, high to low", value: "price:desc" },
]} />
<Hits<Product> hitComponent={ProductHit} />
<Pagination />
</HeadbandProvider>
);
}Render <ProductSearch /> from any page. You should see 20 results with Category and Brand counts. Typing jacket gives 5 highlighted hits, and "Price, low to high" puts the cheapest first.
- The facet and sort fields must be in the index's
filterableAttributesandsortableAttributeson Meilisearch and Visor. The seed script sets them. Other engines differ: see Engine Compatibility. - The components are styled with Tailwind classes. With Tailwind v4, add
@source "../node_modules/@headband/react";toapp/globals.css, or they render unstyled. See React SDK. - The search key in the page can read every field you index. Index only what the page may show. See What a Search Key Can Read.
- Version 0.1.1 has no filter helpers (
filterEq,orFilters), nomodeprop and no experiment helpers. Those are in 0.2.0, on npm since 2026-10-22. Stay on 0.1.1 for this page: in 0.2.0 aRefinementListrenders without end and never searches, which 0.2.1 fixes. - To keep even the search key off the page, search from a Route Handler instead, as in the Node path.
Example app: headband-examples/nextjs
A Plain JS Page
One HTML file and one script, no framework and no build step. The page calls POST /v1/search with fetch() and the search key, filters by category and highlights matches.
cd headband-examples/browser-js
cp .env.example .env # admin key, for seeding only
cp config.example.js config.js # search key, for the page
node --env-file=.env seed.mjs # creates the index, 10 products
node serve.mjs # http://localhost:4173 (any static server works)const { HEADBAND_URL, HEADBAND_SEARCH_KEY, HEADBAND_INDEX } = window.HEADBAND_CONFIG;
async function search(q, category) {
const body = { index: HEADBAND_INDEX, q, limit: 20, attributesToHighlight: ["title"] };
if (category) body.filter = `category = "${category}"`;
const res = await fetch(`${HEADBAND_URL}/v1/search`, {
method: "POST",
headers: {
Authorization: `Bearer ${HEADBAND_SEARCH_KEY}`, // hb_src_... only
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`search failed: ${res.status}`);
const data = await res.json();
// data.hits[i]._formatted.title has the matches wrapped in <em>
return data;
}Open the page: it shows 10 results. Type runing (with the typo) and you get the two running shoes, highlighted. Pick Jackets and you get 3.
_formatted holds text from your documents with <em> tags added. Build the highlight from text nodes, as the example does, rather than setting innerHTML.
Example app: headband-examples/browser-js
A Node Backend
The whole flow with plain fetch and no SDK: create an index, configure it, add documents, search with a filter and a sort, multi-search, read stats, and delete. Then a small HTTP server that answers GET /search?q= with the search key, so no key reaches the browser. This is the pattern for Remix loaders, SvelteKit and Astro endpoints, Next.js Route Handlers and any other server code. Node 20.6 or later; nothing to install.
cd headband-examples/node-backend
cp .env.example .env # HEADBAND_ADMIN_KEY, HEADBAND_SEARCH_KEY
node --env-file=.env walkthrough.mjs # the whole flow, then deletes the index
KEEP_INDEX=1 node --env-file=.env walkthrough.mjs
node --env-file=.env server.mjs # curl "localhost:3001/search?q=jacket"const BASE = process.env.HEADBAND_URL || "https://headband.dev";
export async function hb(method, path, key, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${data?.code ?? ""}`);
return data;
}
// Meilisearch writes return a taskUid to poll. The other engines' tasks are already done.
export async function waitForTask(key, index, taskUid) {
if (taskUid == null) return;
for (;;) {
const task = await hb("GET", `/v1/tasks/${taskUid}?index=${index}`, key);
if (task.status === "succeeded") return task;
if (task.status === "failed") throw new Error(JSON.stringify(task.error));
await new Promise((r) => setTimeout(r, 250));
}
}const created = await hb("POST", "/v1/indexes", ADMIN, { uid: INDEX, primaryKey: "id" });
await waitForTask(ADMIN, INDEX, created.taskUid);
const settings = await hb("PATCH", `/v1/indexes/${INDEX}/settings`, ADMIN, {
searchableAttributes: ["title", "brand", "category"],
filterableAttributes: ["brand", "category", "price"],
sortableAttributes: ["price"],
});
await waitForTask(ADMIN, INDEX, settings.taskUid);
const added = await hb("POST", `/v1/documents?index=${INDEX}&primaryKey=id`, ADMIN, products);
await waitForTask(ADMIN, INDEX, added.taskUid);
const result = await hb("POST", "/v1/search", SEARCH, {
index: INDEX,
q: "running",
filter: 'category = "shoes" AND price < 200',
sort: ["price:asc"],
facets: ["brand"],
});
const multi = await hb("POST", "/v1/multi-search", SEARCH, {
queries: [
{ indexUid: INDEX, q: "jacket", limit: 3 },
{ indexUid: INDEX, q: "", filter: 'brand = "Northwind"', limit: 5 },
],
});
const stats = await hb("GET", `/v1/indexes/${INDEX}/stats`, ADMIN); // admin key onlyimport http from "node:http";
import { hb } from "./headband.mjs";
const SEARCH = process.env.HEADBAND_SEARCH_KEY; // hb_src_..., stays on the server
const INDEX = process.env.HEADBAND_INDEX || "products";
http
.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/search") return res.writeHead(404).end();
const q = (url.searchParams.get("q") ?? "").slice(0, 500);
try {
const result = await hb("POST", "/v1/search", SEARCH, { index: INDEX, q, limit: 10 });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ total: result.estimatedTotalHits, hits: result.hits }));
} catch {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "search failed" }));
}
})
.listen(Number(process.env.PORT || 3001));The walkthrough prints the created index and its engine, then 2 hits for running sorted by price with brand counts, 3 and 4 hits from the multi-search, and numberOfDocuments 10. The filter and sort syntax here is Meilisearch's, which Visor and Typesense also accept. On Elasticsearch an exact filter on a text field needs brand.keyword: see Index Into More Than One Engine.
Example app: headband-examples/node-backend
A Python Backend
The same flow in Python 3 with only the standard library (urllib), so it drops into Django, Flask, FastAPI or a worker without new dependencies. Rails and Laravel apps follow the same calls with their own HTTP client.
cd headband-examples/python-backend
cp .env.example .env # HEADBAND_ADMIN_KEY, HEADBAND_SEARCH_KEY
python3 walkthrough.py # create, settings, documents, search, multi-search, stats, delete
python3 server.py # curl "localhost:3002/search?q=jacket"import json, os, time, urllib.error, urllib.request
BASE = os.environ.get("HEADBAND_URL", "https://headband.dev")
class HeadbandError(Exception):
def __init__(self, status, body):
super().__init__(f"{status} {body}")
self.status, self.body = status, body
def hb(method, path, key, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {key}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as res:
text = res.read().decode()
return json.loads(text) if text else None
except urllib.error.HTTPError as err:
raise HeadbandError(err.code, err.read().decode()) from None
def wait_for_task(key, index, task_uid):
"""Meilisearch writes return a task to poll; the other engines' tasks are already done."""
if task_uid is None:
return
while True:
task = hb("GET", f"/v1/tasks/{task_uid}?index={index}", key)
if task["status"] == "succeeded":
return task
if task["status"] == "failed":
raise RuntimeError(task.get("error"))
time.sleep(0.25)result = hb("POST", "/v1/search", SEARCH_KEY, {
"index": "products",
"q": "running",
"filter": 'category = "shoes" AND price < 200',
"sort": ["price:asc"],
"facets": ["brand"],
})
print(result["estimatedTotalHits"], [h["title"] for h in result["hits"]])The walkthrough prints the same results as the Node one. Keep the admin key in your server settings, never in a template.
Example app: headband-examples/python-backend
Database Sync With Drizzle
Your Postgres tables stay the source of truth. @headband/drizzle wraps your Drizzle db so every insert, update and delete is captured, and the headband CLI creates the index, backfills it and pushes the changes. You need these versions, all on npm:
| Package | Version |
|---|---|
@headband/sync | 0.1.1 |
@headband/drizzle | 0.1.1 |
@headband/cli | 0.6.1 |
drizzle-orm | 0.40 or later, below 1.0 (peer) |
Prisma, TypeORM and Turbine have no adapter yet. With those, use the Postgres connector or managed Sync.
import { defineConfig, rel } from "@headband/sync";
import { drizzleAdapter } from "@headband/drizzle";
import { db } from "./db";
import * as schema from "./schema";
const config = defineConfig({
url: "https://headband.dev",
// The CLI login key is org-wide: without projectId, schema apply uses your Default project.
projectId: process.env.HEADBAND_PROJECT_ID!,
indexes: [
{
name: "products",
source: "products",
primaryKey: "id",
shouldIndex: (row) => row.active === true,
fields: { title: true, price: true, brandName: rel("brands", "name") },
settings: {
searchableAttributes: ["title", "brandName"],
filterableAttributes: ["price", "brandName"],
sortableAttributes: ["price"],
},
},
],
sync: { mode: "outbox" },
});
export default config;
// 0.1.0 types db.execute more narrowly than drizzle-orm does, so a strict
// TypeScript build needs this cast. It is safe: the adapter only calls execute.
type AdapterDb = Parameters<typeof drizzleAdapter>[0]["db"];
export const adapter = drizzleAdapter({ db: db as unknown as AdapterDb, schema, config });import { withHeadbandSync } from "@headband/drizzle";
import { db } from "./db";
import { adapter } from "./headband.config";
// Use syncedDb.insert/update/delete/transaction exactly like db.
export const syncedDb = withHeadbandSync(db, { adapter, mode: "outbox" });cd headband-examples/drizzle-sync
npm install
cp .env.example .env # DATABASE_URL, HEADBAND_PROJECT_ID, the two keys
npm run db:setup # tables and 5 products (1 inactive)
npx headband login # once: approve the device code in your browser
set -a; source .env; set +a # the CLI does not read .env
# Schema commands need your login's key, so hide the admin key from them.
env -u HEADBAND_API_KEY npx headband schema diff
env -u HEADBAND_API_KEY npx headband schema generate
env -u HEADBAND_API_KEY npx headband schema apply --yes
# 4 document(s) pushed, 1 row(s) left out by shouldIndex
npx headband sync init --apply # creates the headband_outbox table
npm run demo # an insert, two updates and a delete through syncedDb
npx headband sync worker --budget-ms 15000
# pushed 2, deleted 2. Outbox depth: 0.
npx headband sync reconcile # missing 0, extra 0schema diff,generate,applyandforgetneed the keyheadband logincreates: it carriesschema.manage, which no dashboard key has. With an admin key they fail with403 permission_denied.- The worker,
sync status,sync reconcileand inline mode use a project admin key inHEADBAND_API_KEY. The CLI prefersHEADBAND_API_KEYover a stored login whenever it is set. headband sync workerkeeps running unless you pass--budget-ms. In production runnpx headband sync worker --loopas its own process;--scaffold dockerwrites a Dockerfile for it.- Deleting the index does not delete its schema state. Run
headband schema forget <index> --yesafter, or later diffs warn about it. - Inline mode pushes from your app process instead of an outbox and a worker. See Self-Hosted Sync and
scripts/demo-inline.tsin the example.
Example app: headband-examples/drizzle-sync