Framework Recipes
Headband adoption starts with the stack you already ship. The React SDK is the only first-party UI package; every other stack calls the same 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
liveUse @headband/react in App Router or Pages Router search surfaces.
npm i @headband/reactGuide: Next.js guide
Remix / React Router
recipeCall /v1/search from a loader, with the search key kept on the server.
curl -X POST https://headband.dev/v1/search -H "Authorization: Bearer $HEADBAND_SEARCH_KEY" -H "Content-Type: application/json" -d '{"index":"products","q":"shoes"}'Code: a working search page
Guide: Server-side search (Node)
Astro
recipeCall /v1/search from a server-rendered page, with the search key kept on the server.
curl -X POST https://headband.dev/v1/search -H "Authorization: Bearer $HEADBAND_SEARCH_KEY" -H "Content-Type: application/json" -d '{"index":"products","q":"shoes"}'Code: a working search page
Guide: Server-side search (Node)
SvelteKit
recipeCall /v1/search from a server load function, with the search key kept on the server.
curl -X POST https://headband.dev/v1/search -H "Authorization: Bearer $HEADBAND_SEARCH_KEY" -H "Content-Type: application/json" -d '{"index":"products","q":"shoes"}'Code: a working search page
Guide: Server-side search (Node)
Rails / Laravel / Django
recipeSend your records with the admin key from a job, and search with the search key from a controller or view.
curl -X POST https://headband.dev/v1/search -H "Authorization: Bearer $HEADBAND_SEARCH_KEY" -H "Content-Type: application/json" -d '{"index":"products","q":"shoes"}'Code: a working search page
Search From Your Server
Each stack below calls POST https://headband.dev/v1/search from server code. Use the full URL: a relative /v1/search calls your own app. Send the search key (hb_src_) in the Authorization header, and the index and query as JSON. Keep the key in a server-side environment variable named HEADBAND_SEARCH_KEY. The admin key (hb_adm_) writes documents and settings. It belongs only in jobs and scripts on your servers, never in a page.
The full request and answer are in Search, the keys in Authentication, and how to send your records in Documents. 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.
SvelteKit
// src/routes/search/+page.server.ts
import { env } from "$env/dynamic/private";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url, fetch }) => {
const q = url.searchParams.get("q") ?? "";
const res = await fetch("https://headband.dev/v1/search", {
method: "POST",
headers: {
Authorization: `Bearer ${env.HEADBAND_SEARCH_KEY}`, // hb_src_..., stays on the server
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) throw new Error(`Headband search failed: ${res.status} ${await res.text()}`);
const { hits, estimatedTotalHits } = await res.json();
return { q, hits, total: estimatedTotalHits };
};Remix / React Router
// app/routes/search.tsx (React Router 7, or Remix 2)
import { Form, useLoaderData } from "react-router"; // Remix 2: "@remix-run/react"
export async function loader({ request }: { request: Request }) {
const q = new URL(request.url).searchParams.get("q") ?? "";
const res = await fetch("https://headband.dev/v1/search", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HEADBAND_SEARCH_KEY}`, // hb_src_..., stays on the server
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) throw new Error(`Headband search failed: ${res.status} ${await res.text()}`);
const { hits, estimatedTotalHits } = await res.json();
return { q, hits, total: estimatedTotalHits };
}
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: { id: string; title: string }) => (
<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 q = Astro.url.searchParams.get("q") ?? "";
const res = await fetch("https://headband.dev/v1/search", {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.HEADBAND_SEARCH_KEY}`, // hb_src_..., stays on the server
"Content-Type": "application/json",
},
body: JSON.stringify({ index: "products", q, limit: 20 }),
});
if (!res.ok) throw new Error(`Headband search failed: ${res.status} ${await res.text()}`);
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
HEADBAND_SEARCH = URI("https://headband.dev/v1/search")
def index
@query = params[:q].to_s
res = Net::HTTP.post(
HEADBAND_SEARCH,
{ index: "products", q: @query, limit: 20 }.to_json,
"Authorization" => "Bearer #{ENV.fetch("HEADBAND_SEARCH_KEY")}", # hb_src_..., server only
"Content-Type" => "application/json"
)
raise "Headband search failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
body = JSON.parse(res.body)
@hits = body["hits"]
@total = body["estimatedTotalHits"]
end
end<?php
// app/Http/Controllers/SearchController.php
// config/services.php: 'headband' => ['search_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', '');
// hb_src_..., read on the server only. Http::post sends the array as JSON.
$body = Http::withToken(config('services.headband.search_key'))
->post('https://headband.dev/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
def search(request):
q = request.GET.get("q", "")
req = urllib.request.Request(
"https://headband.dev/v1/search",
data=json.dumps({"index": "products", "q": q, "limit": 20}).encode(),
headers={
# hb_src_..., read on the server only
"Authorization": f"Bearer {os.environ['HEADBAND_SEARCH_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as res: # raises on 4xx and 5xx
body = json.load(res)
return render(
request,
"search/results.html",
{"q": q, "hits": body["hits"], "total": body.get("estimatedTotalHits", 0)},
)These pages search when the form is sent. For search as you type outside React, call the same endpoint from the browser with the search key: a search key is meant to be public, but it reads every field you index.
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.
git clone https://github.com/ZVN-DEV/headband-examples.git
cd headband-examples/connectors
npm install
cp .env.example .env # then fill in the values
npm run sync:postgres # or sync:mysql, sync:shopify, sync:stripe, sync:batadbEvery 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.
| Variable | Required | Default | What it does |
|---|---|---|---|
HEADBAND_ADMIN_KEY | yes | A project admin key (hb_adm_...). Keep it on the server. | |
HEADBAND_URL | no | https://headband.dev | API base URL. |
HEADBAND_INDEX | no | the table name, shopify_products or stripe_catalog | Index to write to. Created on the first run if it does not exist. |
HEADBAND_ENGINE | no | the project's default | Engine for a new index: meilisearch, typesense, elasticsearch or visor. |
HEADBAND_FIELDS | no | every column | Comma-separated fields to keep. The primary key must stay in. |
HEADBAND_EXCLUDE_FIELDS | no | none | Comma-separated fields to drop, such as email. |
HEADBAND_DELETE_IDS | no | none | Comma-separated document ids to delete from the index. |
HEADBAND_BATCH_SIZE | no | 10000 | Documents per bulk batch (100 to 50000). Requests are also split to stay under 4 MiB. |
HEADBAND_RETRY_ATTEMPTS | no | 3 | Attempts per request. A 429 or 5xx is retried with backoff, waiting at least Retry-After. |
HEADBAND_RECONCILE | no | false | After writing, compare the source count with the index's document count. |
HEADBAND_RECONCILE_STRICT | no | false | Exit 1 when the counts differ. |
HEADBAND_SYNC_LOG_PATH | no | none | Append one JSON line per step to this file, with keys redacted. |
HEADBAND_DEAD_LETTER_PATH | no | none | Append failed bulk or delete payloads to this file, to replay later. |
HEADBAND_REPORT_RUNS | no | false | Record each run with POST /v1/connectors/runs, so it shows on the project overview. |
Postgres / Neon / Supabase
recipeRun 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.)
| Variable | Required | Default | What it does |
|---|---|---|---|
POSTGRES_DATABASE_URL | yes (or DATABASE_URL) | Connection string, from Neon, Supabase, RDS or your own server. | |
POSTGRES_TABLE | no | products | Table or view to read. A plain name, no schema. |
POSTGRES_PRIMARY_KEY | no | id | Primary key column. It becomes the document id. |
POSTGRES_SYNC_LIMIT | no | 1000 | Most rows read per run, lowest keys first. |
POSTGRES_DELETE_IDS | no | Ids to delete from the index. |
- The query is select * from <table> order by <primary key> asc limit <limit>. To shape the documents, point POSTGRES_TABLE at a view.
- numeric and bigint columns arrive as strings, because that is how node-postgres reads 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://headband.dev";
const KEY = process.env.HEADBAND_ADMIN_KEY; // hb_adm_..., server-side only
const TABLE = process.env.POSTGRES_TABLE ?? "products"; // a plain table or view name
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 });
console.log(`Deleted ${DELETE_IDS.join(",")}: HTTP ${res.status}`);
}MySQL / PlanetScale
recipeExport 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.)
| Variable | Required | Default | What it does |
|---|---|---|---|
MYSQL_URL | yes (or DATABASE_URL) | mysql://user:password@host:3306/database | |
MYSQL_TABLE | no | products | Table or view to read. |
MYSQL_PRIMARY_KEY | no | id | Primary key column. |
MYSQL_SYNC_LIMIT | no | 1000 | Most rows read per run. |
MYSQL_SSL_MODE | no | off | REQUIRED turns TLS on; VERIFY_IDENTITY also checks the certificate. PlanetScale needs one of them. |
MYSQL_TIMEZONE | no | Z (UTC) | Time zone DATETIME and TIMESTAMP values are read in. |
MYSQL_DELETE_IDS | no | Ids to delete from the index. |
- It uses the mysql2 package, so you need no mysql command-line client.
- TINYINT(1) columns are sent as true or false, and DECIMAL columns as numbers.
Shopify
recipeMirror 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.)
| Variable | Required | Default | What it does |
|---|---|---|---|
SHOPIFY_SHOP_DOMAIN | yes | your-store.myshopify.com | |
SHOPIFY_ADMIN_TOKEN | yes | Admin API access token of a custom app with the read_products scope. | |
SHOPIFY_API_VERSION | no | 2026-07 | Admin API version. |
SHOPIFY_PRODUCT_QUERY | no | status:active | Shopify search filter. Set it empty to read every product. |
SHOPIFY_PRODUCT_LIMIT | no | 100 | Most products read per run. |
SHOPIFY_PAGE_SIZE | no | 50 | Products per GraphQL page, at most 250. |
SHOPIFY_DELETE_PRODUCT_IDS | no | Product ids or gids to delete from the index. |
- Each product becomes one document with the id shopify_product_<numeric id>, its variants nested, minPrice and maxPrice, and a searchableText field. HTML is stripped from the description. No customer, order or payment data is read.
- lib/shopify-catalog.mjs also exports verifyShopifyWebhookHmac({ rawBody, hmacHeader, secret }) for a products/update or products/delete webhook route of your own. Pass the raw request body, not parsed JSON, and the X-Shopify-Hmac-Sha256 header.
Stripe catalog
recipeMirror 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.)
| Variable | Required | Default | What it does |
|---|---|---|---|
STRIPE_SECRET_KEY | yes | A restricted key with read access to Products and Prices is enough. | |
STRIPE_ACTIVE_ONLY | no | true | Only active products and prices. |
STRIPE_PRODUCT_LIMIT | no | 100 | Most products read per run. |
STRIPE_PRICE_LIMIT | no | 100 | Most prices read per run. |
STRIPE_DELETE_PRODUCT_IDS | no | Product ids (prod_...) to delete from the index. |
- Each product becomes one document with the id stripe_product_<prod id> and its prices nested. Nothing about customers or payments is read.
BataDB
recipeSync 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.)
| Variable | Required | Default | What it does |
|---|---|---|---|
BATADB_DATABASE_URL | yes | Your BataDB connection string. BataDB speaks Postgres. | |
BATADB_TABLE | no | products | Table or view to read. |
BATADB_DELETE_IDS | no | Ids to delete from the index. |
- It is the Postgres script: with BATADB_DATABASE_URL set it reads BataDB and records its runs as batadb-search. Every Postgres variable above works too.
- Use HEADBAND_FIELDS or HEADBAND_EXCLUDE_FIELDS to keep private or internal columns out of the index.
Hosted connector scheduler
RoadmapHeadband 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.
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.
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_DATABASE_URL }}
HEADBAND_ADMIN_KEY: ${{ secrets.HEADBAND_ADMIN_KEY }}
POSTGRES_TABLE: products
HEADBAND_RECONCILE: "true"
HEADBAND_REPORT_RUNS: "true"A/B Tests
Experiments between engines or settings, with hosted assignment, tagged searches and outcome events, are in A/B Test Engines.
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.
{
"data": {
"frameworks": [{ "slug": "nextjs-react", "status": "live" }],
"connectors": [{ "slug": "postgres", "status": "recipe" }],
"experiments": [{ "slug": "engine-ab-test", "status": "recipe" }]
}
}