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

live

Use @headband/react in App Router or Pages Router search surfaces.

npm i @headband/react

Guide: Next.js guide

Remix / React Router

recipe

Call /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

recipe

Call /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

recipe

Call /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

recipe

Send 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

Guide: Server-side search (Python, or any HTTP client)

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
// 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
// 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
---
// 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

Rails: app/controllers/search_controller.rb
# 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
Laravel: app/Http/Controllers/SearchController.php
<?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,
        ]);
    }
}
Django: search/views.py
# 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.

terminal
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:batadb

Every 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.

VariableRequiredDefaultWhat it does
HEADBAND_ADMIN_KEYyesA project admin key (hb_adm_...). Keep it on the server.
HEADBAND_URLnohttps://headband.devAPI base URL.
HEADBAND_INDEXnothe table name, shopify_products or stripe_catalogIndex to write to. Created on the first run if it does not exist.
HEADBAND_ENGINEnothe project's defaultEngine for a new index: meilisearch, typesense, elasticsearch or visor.
HEADBAND_FIELDSnoevery columnComma-separated fields to keep. The primary key must stay in.
HEADBAND_EXCLUDE_FIELDSnononeComma-separated fields to drop, such as email.
HEADBAND_DELETE_IDSnononeComma-separated document ids to delete from the index.
HEADBAND_BATCH_SIZEno10000Documents per bulk batch (100 to 50000). Requests are also split to stay under 4 MiB.
HEADBAND_RETRY_ATTEMPTSno3Attempts per request. A 429 or 5xx is retried with backoff, waiting at least Retry-After.
HEADBAND_RECONCILEnofalseAfter writing, compare the source count with the index's document count.
HEADBAND_RECONCILE_STRICTnofalseExit 1 when the counts differ.
HEADBAND_SYNC_LOG_PATHnononeAppend one JSON line per step to this file, with keys redacted.
HEADBAND_DEAD_LETTER_PATHnononeAppend failed bulk or delete payloads to this file, to replay later.
HEADBAND_REPORT_RUNSnofalseRecord each run with POST /v1/connectors/runs, so it shows on the project overview.

Postgres / Neon / Supabase

recipe

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.)

VariableRequiredDefaultWhat it does
POSTGRES_DATABASE_URLyes (or DATABASE_URL)Connection string, from Neon, Supabase, RDS or your own server.
POSTGRES_TABLEnoproductsTable or view to read. A plain name, no schema.
POSTGRES_PRIMARY_KEYnoidPrimary key column. It becomes the document id.
POSTGRES_SYNC_LIMITno1000Most rows read per run, lowest keys first.
POSTGRES_DELETE_IDSnoIds 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.

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

recipe

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.)

VariableRequiredDefaultWhat it does
MYSQL_URLyes (or DATABASE_URL)mysql://user:password@host:3306/database
MYSQL_TABLEnoproductsTable or view to read.
MYSQL_PRIMARY_KEYnoidPrimary key column.
MYSQL_SYNC_LIMITno1000Most rows read per run.
MYSQL_SSL_MODEnooffREQUIRED turns TLS on; VERIFY_IDENTITY also checks the certificate. PlanetScale needs one of them.
MYSQL_TIMEZONEnoZ (UTC)Time zone DATETIME and TIMESTAMP values are read in.
MYSQL_DELETE_IDSnoIds 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

recipe

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.)

VariableRequiredDefaultWhat it does
SHOPIFY_SHOP_DOMAINyesyour-store.myshopify.com
SHOPIFY_ADMIN_TOKENyesAdmin API access token of a custom app with the read_products scope.
SHOPIFY_API_VERSIONno2026-07Admin API version.
SHOPIFY_PRODUCT_QUERYnostatus:activeShopify search filter. Set it empty to read every product.
SHOPIFY_PRODUCT_LIMITno100Most products read per run.
SHOPIFY_PAGE_SIZEno50Products per GraphQL page, at most 250.
SHOPIFY_DELETE_PRODUCT_IDSnoProduct 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

recipe

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.)

VariableRequiredDefaultWhat it does
STRIPE_SECRET_KEYyesA restricted key with read access to Products and Prices is enough.
STRIPE_ACTIVE_ONLYnotrueOnly active products and prices.
STRIPE_PRODUCT_LIMITno100Most products read per run.
STRIPE_PRICE_LIMITno100Most prices read per run.
STRIPE_DELETE_PRODUCT_IDSnoProduct 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.

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.

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.

.github/workflows/headband-sync.yml
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

GET/v1/connectors

Return 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 noneReturns 200
Response
{
  "data": {
    "frameworks": [{ "slug": "nextjs-react", "status": "live" }],
    "connectors": [{ "slug": "postgres", "status": "recipe" }],
    "experiments": [{ "slug": "engine-ab-test", "status": "recipe" }]
  }
}