Build with Headband
Framework recipes
Add search to the stack you already use: React through our SDK, every other stack through the REST API from its server code.
Each recipe links to a step-by-step path in Integrate Headband, with a working app in the headband-examples repo.
Next.js + React
React SDKUse @headband/react in App Router or Pages Router search surfaces.
Install: npm i @headband/react
Guide: Next.js guide
Remix / React Router
REST APICall /v1/search from a loader, with the search key kept on the server.
Code: a working search page
Guide: Server-side search (Node)
Astro
REST APICall /v1/search from a server-rendered page, with the search key kept on the server.
Code: a working search page
Guide: Server-side search (Node)
SvelteKit
REST APICall /v1/search from a server load function, with the search key kept on the server.
Code: a working search page
Guide: Server-side search (Node)
Rails / Laravel / Django
REST APISend your records with the admin key from a job, and search with the search key from a controller or view.
Code: a working search page
Search from your server
Each stack below sends the same request from its server code:
POST https://api.headband.dev/v1/search, the full URL: a relative/v1/searchcalls your own app.- The search key (
hb_src_) in theAuthorizationheader, kept in a server-side environment variable namedHEADBAND_SEARCH_KEY. - The index and the query as JSON.
SEARCH_KEY=$HEADBAND_SEARCH_KEY
curl -X POST https://api.headband.dev/v1/search \
-H "Authorization: Bearer $SEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{"index":"products","q":"shoes"}'- The admin key (
hb_adm_) writes documents and settings. It belongs only in jobs and scripts on your servers, never in a page. - A request body can be at most 4 MiB, so a large import goes in pieces: the import script does that from any stack.
- Managed Sync reads only Postgres, so a Rails, Laravel or Django app on MySQL or SQLite sends its own records.
- The full request and answer are in Search, the keys in Authentication, and sending records in Documents.
SvelteKit
// src/routes/search/+page.server.ts
import { error } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import type { PageServerLoad } from "./$types";
const HOST = "https://api.headband.dev";
export const load: PageServerLoad = async ({ url, fetch }) => {
const q = url.searchParams.get("q") ?? "";
// hb_src_..., read on the server only
const key = env.HEADBAND_SEARCH_KEY;
const res = await fetch(`${HOST}/v1/search`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) error(res.status, "Search failed");
const { hits, estimatedTotalHits } = await res.json();
return { q, hits, total: estimatedTotalHits };
};Remix / React Router
// app/routes/search.tsx (React Router 7)
// On Remix 2, import from "@remix-run/react" instead.
import { Form, useLoaderData } from "react-router";
const HOST = "https://api.headband.dev";
export async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
// hb_src_..., read on the server only
const key = process.env.HEADBAND_SEARCH_KEY;
const res = await fetch(`${HOST}/v1/search`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) {
throw new Response("Search failed", { status: res.status });
}
const { hits, estimatedTotalHits } = await res.json();
return { q, hits, total: estimatedTotalHits };
}
type Hit = { id: string; title: string };
export default function Search() {
const { q, hits, total } = useLoaderData<typeof loader>();
return (
<>
<Form>
<input name="q" defaultValue={q} />
</Form>
<p>{total} results</p>
<ul>
{hits.map((hit: Hit) => (
<li key={hit.id}>{hit.title}</li>
))}
</ul>
</>
);
}Astro
---
// src/pages/search.astro
// Needs an SSR adapter, so the page runs on each request.
export const prerender = false;
const HOST = "https://api.headband.dev";
const q = Astro.url.searchParams.get("q") ?? "";
// hb_src_..., read on the server only
const key = import.meta.env.HEADBAND_SEARCH_KEY;
const res = await fetch(`${HOST}/v1/search`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) throw new Error(`Search: ${res.status}`);
const { hits, estimatedTotalHits } = await res.json();
---
<form><input name="q" value={q} /></form>
<p>{estimatedTotalHits} results</p>
<ul>{hits.map((hit) => <li>{hit.title}</li>)}</ul>Rails, Laravel and Django
# app/controllers/search_controller.rb
require "net/http"
require "json"
class SearchController < ApplicationController
HOST = "https://api.headband.dev"
SEARCH_URL = URI("#{HOST}/v1/search")
def index
@query = params[:q].to_s
body = { index: "products", q: @query, limit: 20 }.to_json
# hb_src_..., read on the server only
key = ENV.fetch("HEADBAND_SEARCH_KEY")
headers = {
"Authorization" => "Bearer #{key}",
"Content-Type" => "application/json"
}
res = Net::HTTP.post(SEARCH_URL, body, headers)
unless res.is_a?(Net::HTTPSuccess)
raise "Search failed: #{res.code}"
end
data = JSON.parse(res.body)
@hits = data["hits"]
@total = data["estimatedTotalHits"]
end
end<?php
// In config/services.php:
// 'headband' => [
// 'key' => env('HEADBAND_SEARCH_KEY'),
// ],
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
public function index(Request $request)
{
$q = (string) $request->query('q', '');
$host = 'https://api.headband.dev';
// hb_src_..., read on the server only
$key = config(
'services.headband.key'
);
// Http::post sends the array as JSON.
$body = Http::withToken($key)
->post("$host/v1/search", [
'index' => 'products',
'q' => $q,
'limit' => 20,
])
->throw()
->json();
return view('search', [
'q' => $q,
'hits' => $body['hits'],
'total' => $body['estimatedTotalHits'] ?? 0,
]);
}
}# search/views.py
import json
import os
import urllib.request
from django.shortcuts import render
HOST = "https://api.headband.dev"
SEARCH_URL = f"{HOST}/v1/search"
def search(request):
q = request.GET.get("q", "")
# hb_src_..., read on the server only
key = os.environ["HEADBAND_SEARCH_KEY"]
payload = {"index": "products", "q": q, "limit": 20}
data = json.dumps(payload).encode()
req = urllib.request.Request(
SEARCH_URL,
data=data,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
# urlopen raises on a 4xx or 5xx answer.
with urllib.request.urlopen(req, timeout=10) as res:
body = json.load(res)
total = body.get("estimatedTotalHits", 0)
return render(
request,
"search/results.html",
{
"q": q,
"hits": body["hits"],
"total": total,
},
)Sync connectors
A connector is a small Node script that you run on your own schedule. Each run reads up to a set number of records from the source, creates the index if it is missing, upserts the records through POST /v1/bulk, deletes the ids you name, runs a sample search, and can compare counts. The scripts are in the connectors/ folder of the headband-examples repo. For Postgres there is also managed Sync (early access), which reads your database's change stream so you run no script.
What the scripts do not do:
- They do not stream changes. There is no change capture, trigger or binlog reader: an edit reaches the index on the next run.
- Headband does not run, schedule or retry them for you. Use cron or a CI schedule.
- A record deleted at the source stays in the index until you pass its id in a delete variable.
Get the scripts
You need Node 20.6 or later.
GH=https://github.com/ZVN-DEV
git clone "$GH/headband-examples.git"
cd headband-examples/connectors
npm install
cp .env.example .env # then fill in the values
# or sync:mysql, sync:shopify, sync:stripe, sync:batadb
npm run sync:postgresEvery script reads these settings. HEADBAND_ADMIN_KEY is the only one you must set: a project admin key, which writes documents, creates the index and reads its document count. A search key cannot read the count, so reconcile needs the admin key.
Postgres / Neon / Supabase Script
Run a scheduled SQL export into /v1/bulk, then validate with /v1/search.
Script: connectors/postgres.mjs. Run it with npm run sync:postgres (Every 15 minutes for active catalogs; hourly or nightly for slower back-office data.)
- The query is
select * from <table> order by <primary key> asc limit <limit>. To shape the documents, pointPOSTGRES_TABLEat a view. numericandbigintcolumns arrive as strings, because that is hownode-postgresreads them. Cast them in the view if you filter or sort on them as numbers.
The whole idea fits in one file. This is connectors/minimal-postgres.mjs, without the retries, logs, projection and reconcile of the full script. Set DATABASE_URL and HEADBAND_ADMIN_KEY, then run node --env-file=.env minimal-postgres.mjs.
import pg from "pg";
const HEADBAND_URL =
process.env.HEADBAND_URL ?? "https://api.headband.dev";
// hb_adm_..., server-side only
const KEY = process.env.HEADBAND_ADMIN_KEY;
// a plain table or view name
const TABLE = process.env.POSTGRES_TABLE ?? "products";
const INDEX = process.env.HEADBAND_INDEX ?? TABLE;
const DELETE_IDS = (process.env.HEADBAND_DELETE_IDS ?? "")
.split(",")
.filter(Boolean);
async function headband(method, path, body) {
const res = await fetch(`${HEADBAND_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
const { rows } = await db.query(
`select * from ${TABLE}
order by id limit 1000`
);
await db.end();
// /v1/bulk does not create the index, so create it once.
// 409 means it exists.
const created = await headband("POST", "/v1/indexes", {
uid: INDEX,
primaryKey: "id",
});
if (created.status >= 400 && created.status !== 409) {
throw new Error(JSON.stringify(created));
}
// One request body may be at most 4 MiB: send 500 rows at a time.
for (let i = 0; i < rows.length; i += 500) {
const documents = rows.slice(i, i + 500);
const res = await headband("POST", "/v1/bulk", {
index: INDEX,
primaryKey: "id",
documents,
});
if (res.status >= 300) {
throw new Error(
`bulk failed: ${res.status} ` +
JSON.stringify(res.data)
);
}
}
console.log(
`Upserted ${rows.length} rows into ` + INDEX
);
// Rows deleted at the source stay in the index until you delete their ids.
if (DELETE_IDS.length > 0) {
const res = await headband(
"DELETE",
`/v1/documents?index=${INDEX}`,
{ ids: DELETE_IDS }
);
const ids = DELETE_IDS.join(",");
console.log(`Deleted ${ids}: HTTP ` + res.status);
}MySQL / PlanetScale Script
Export MySQL rows through the mysql CLI and batch them into Headband.
Script: connectors/mysql.mjs. Run it with npm run sync:mysql (Every 15-60 minutes for product/search tables; nightly for low-change operational data.)
- It uses the
mysql2package, so you need nomysqlcommand-line client. TINYINT(1)columns are sent astrueorfalse, andDECIMALcolumns as numbers.
Shopify Script
Mirror Shopify products/variants into a searchable catalog index.
Script: connectors/shopify.mjs. Run it with npm run sync:shopify (Every 5-15 minutes for product catalogs; combine with app-owned webhooks for hot product deltas.)
- Each product becomes one document with the id
shopify_product_<numeric id>, its variants nested,minPriceandmaxPrice, and asearchableTextfield. HTML is stripped from the description. No customer, order or payment data is read. lib/shopify-catalog.mjsalso exportsverifyShopifyWebhookHmac({ rawBody, hmacHeader, secret })for aproducts/updateorproducts/deletewebhook route of your own. Pass the raw request body, not parsed JSON, and theX-Shopify-Hmac-Sha256header.
Stripe catalog Script
Mirror products/prices into an index for account portals, docs, and internal tools.
Script: connectors/stripe.mjs. Run it with npm run sync:stripe (Hourly for active catalogs; nightly when products/prices only change through release workflows.)
- Each product becomes one document with the id
stripe_product_<prod id>and its prices nested. Nothing about customers or payments is read.
BataDB Script
Sync BataDB tables into Headband indexes with the bounded backfill script, field projection, and explicit deletes.
Script: connectors/postgres.mjs. Run it with npm run sync:batadb (Every 5-15 minutes for active tables; nightly reconciliation for low-change tables.)
- It is the Postgres script: with
BATADB_DATABASE_URLset it reads BataDB and records its runs asbatadb-search. Every Postgres variable above works too. - Use
HEADBAND_FIELDSorHEADBAND_EXCLUDE_FIELDSto keep private or internal columns out of the index.
Run on a schedule
This GitHub Actions workflow (.github/workflows/headband-sync.yml in the connectors folder) runs the Postgres script every 15 minutes. Put the connection string and the admin key in the repository's secrets.
name: Headband sync
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch: {}
concurrency:
group: headband-sync
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: node postgres.mjs
env:
POSTGRES_DATABASE_URL: ${{ secrets.POSTGRES_URL }}
HEADBAND_ADMIN_KEY: ${{ secrets.HB_ADMIN_KEY }}
POSTGRES_TABLE: products
HEADBAND_RECONCILE: "true"
HEADBAND_REPORT_RUNS: "true"Deletes and reconcile
The scripts only add and update. When you delete records at the source, pass their ids in the delete variable on the next run. HEADBAND_RECONCILE=true then tells you whether the index and the source agree. For Postgres and MySQL the source count is the whole table, so it matches only when the table has no more rows than the sync limit. For Shopify and Stripe it is the number of records the run read.
Hosted connector scheduler Roadmap
Headband does not run connector scripts for you yet: no hosted schedule, stored source secrets, retry queue or connector dashboard. Until it does, run the scripts above on your own schedule. Managed Sync is the hosted option for Postgres today.
Connector catalog API
/v1/connectorsReturn public framework, sync connector, and experiment recipe metadata. This endpoint exposes no tenant data or secrets.
Use this for docs automation, integration pages, and internal adoption checklists. Each connector's command runs from the connectors folder of the headband-examples repo.
- Auth
- none
- Returns
- 200
{
"data": {
"frameworks": [{ "slug": "nextjs-react", "status": "live" }],
"connectors": [{ "slug": "postgres", "status": "recipe" }],
"experiments": [{ "slug": "engine-ab-test", "status": "recipe" }]
}
}A/B tests