← All articles
Guide · September 2026

Headless search: what the response carries, and the URL your front end composes

A headless front end has no theme to render a result: it receives data and builds the page itself. So it needs to know exactly what search returns, and what it will have to fetch elsewhere. Here is the Heurix response field by field, with real calls, and the limits an integrator runs into on day one.

The responses shown here come from the engine at the version served in production on 13 September 2026, on a three-product catalog indexed for this article: two variants of the same screw and one nut. Product values and API messages are shown as the API returns them, in French.

One call, the whole product

By default, each result carries the complete product as you indexed it. This is deliberate: the client never has to make a second call to render a result card. For a headless front end, that is one network round trip fewer per search.

curl -X POST https://api.heurix.fr/v1/index/boutique/search \
  -H "Authorization: Bearer hxp_your_public_key" \
  -H "Content-Type: application/json" \
  -d '{"q": "vis inox m8", "limit": 1, "exclude_description": true}'
{
  "query": "vis inox m8",
  "tokens": [
    "vis",
    "inox",
    "m8"
  ],
  "total": 3,
  "offset": 0,
  "limit": 1,
  "hits": [
    {
      "product": {
        "id": "44556677889901",
        "platform_id": 44556677889901,
        "produit_id": "8801",
        "handle": "vis-tete-hexagonale-inox-a2",
        "ref": "VIS-M8X20-A2",
        "name": "Vis tête hexagonale M8x20 inox A2",
        "price": 12.9,
        "compare_at_price": 15.9,
        "stock": true,
        "category": "Visserie",
        "url": "https://boutique.example/products/vis-tete-hexagonale-inox-a2",
        "image": "https://cdn.example/vis-m8x20.jpg"
      },
      "score": 54.8,
      "in_stock": true,
      "matched": [
        "terme 'vis' (depuis 'vis', x1.00)",
        "terme 'vis-m8x20-a2' (depuis 'vis', x0.22)",
        "terme 'inox' (depuis 'inox', x1.00)",
        "annotation #DIAM_M8",
        "annotation #DIAM_M8_INOX",
        "annotation #FAM_VIS",
        "annotation #MAT_INOX"
      ],
      "pinned": false,
      "buried": false
    }
  ],
  "fallback": false
}

The indexed product also had a margin field. It is not in the response: a public key never receives it. Here is where each field comes from.

FieldWhere it comes from
product.*Your last indexing call, as sent. Nothing is read back from your platform at search time.
product.priceThe price field the calling key serves: price by default.
product.marginRemoved for a public key, served to a server key.
score
matched
Computed by this search: relevance, and its readable reasons.
in_stockDerived from your stock field.
pinned
buried
Query rules set in the console.
fallbacktrue when hits holds your featured products because nothing matched; total then stays at 0.
facets
price_filter
filters_unknown
suggested_category
highlighted_bundle
Present only when they have something to say. Their absence is the normal case.

What the response does not carry, unless you indexed it: the product page URL, the image, variants and their options, the currency, real-time stock and price. A price changed in your back office only exists for search after your next indexing call. Payload size is controlled with exclude_description: on a real catalog, the description accounts for more than 80% of a product's weight in the response.

The engine never composes a URL; the front end does

In the response above, url is present because it was indexed. The second variant of the same screw has none, and the engine does not make one up: it knows neither your domain, nor your sales channel, nor your routes.

That is what makes the response usable without a theme. Index the stable part, a handle or a slug, and compose the path in the front end:

const link = p.url || "/products/" + encodeURIComponent(p.handle);

A relative path stays correct on the production domain, on localhost and on a preview domain. It is the rule our own Shopify storefront script applies: a password-protected store, the state of every merchant preparing to open, does not provide a public product URL, and the path is rebuilt from the handle.

ids_only: when your platform hydrates its own product data

If your front end already reads its products elsewhere, from a CMS, a PIM or your platform's API, ask for the order only:

{"q": "inox m8", "ids_only": true}

{"total": 3, "ids": ["44556677889903", "44556677889901", "44556677889902"]}

id_field returns another field instead of id: the identifier your platform expects, a handle, a slug.

{"q": "inox m8", "ids_only": true, "id_field": "handle"}

{"total": 3, "ids": ["ecrou-hexagonal-inox-a2", "vis-tete-hexagonale-inox-a2", "vis-tete-hexagonale-inox-a2"]}

Three identifiers, two distinct. A catalog indexed per variant returns the product handle once per matching variant: deduplicate before hydrating, keeping the first occurrence to preserve the order. A product without the requested field is dropped, and counted:

{"q": "inox m8", "ids_only": true, "id_field": "url"}

{"total": 3, "ids": ["https://boutique.example/products/vis-tete-hexagonale-inox-a2"], "ignores": 2, "champ_manquant": "url"}

What ids_only leaves out

The light response carries only total and ids. Measured on the same catalog:

  • Requested facets are missing, with no warning. The same query in full mode returns "facets": {"FAM": {"FAM_VIS": 2, "FAM_ECROU": 1}}.
  • Zero results returns {"total": 0, "ids": []}. Full mode returns your featured products with "fallback": true.
  • price_filter, filters_unknown and matched do not appear either.

If your results page shows facets, full mode with exclude_description remains the call to make.

The public key, and what allowed_origins imposes

A headless front end calls from the browser, with a public hxp_ key created by your server key. It searches, browses a catalog and sends events, nothing else: a public key that tries to index gets a 403. Its quota is that of the server key that created it.

curl -X POST https://api.heurix.fr/v1/keys/public \
  -H "Authorization: Bearer YOUR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_origins": "https://www.boutique.example, boutique.example, localhost:3000"}'

{"key": "hxp_...", "allowed_origins": "www.boutique.example,boutique.example,localhost", "scope": "search, browse, events"}

The comparison is on the host alone: scheme and port are stripped, both when stored and on every request. Three consequences, measured:

  • localhost is accepted on every port. Declared as localhost:3000, it also lets an http://localhost:5173 origin through.
  • No wildcard. *.boutique.example is rejected with a 422, and so is *. An empty field is the only way to allow every origin.
  • Each preview domain is a distinct host, so it is rejected.
Origin: https://boutique-git-panier-acme.vercel.app

HTTP 403
{"detail": "Origine 'boutique-git-panier-acme.vercel.app' non autorisée pour cette clé publique. Domaines autorisés : www.boutique.example, boutique.example, localhost."}

Vercel and Netlify create one domain per deployment, which the list cannot enumerate. Today, a preview environment has a single way out: a public key with no origin restriction, usable from any site for as long as it exists.

Server-side rendering: today, two options, neither satisfactory

Server-side rendering calls from Node, not from a browser: the request carries no Origin header, and a restricted public key rejects it.

HTTP 403
{"detail": "Origine 'absente' non autorisée pour cette clé publique. Domaines autorisés : www.boutique.example, boutique.example, localhost."}

Two options exist, and each has a cost.

  • A public key with no origin restriction. The server calls it without trouble. But the restriction is gone for everyone: if that key is also served to the browser, any site can use it, on your quota.
  • The server key. It needs no Origin, but it sees the whole catalog: the margin comes out in the response, and the price configured on a public key does not apply. Anything your render passes to the browser for hydration carries those fields.

The same product, for the same query, depending on the key:

// server key
{
  "id": "44556677889903",
  "platform_id": 44556677889903,
  "produit_id": "8802",
  "handle": "ecrou-hexagonal-inox-a2",
  "ref": "ECR-M8-A2",
  "name": "Écrou hexagonal M8 inox A2",
  "price": 6.2,
  "margin": 2.0,
  "stock": true,
  "category": "Visserie",
  "featured": true
}

// public key
{
  "id": "44556677889903",
  "platform_id": 44556677889903,
  "produit_id": "8802",
  "handle": "ecrou-hexagonal-inox-a2",
  "ref": "ECR-M8-A2",
  "name": "Écrou hexagonal M8 inox A2",
  "price": 6.2,
  "stock": true,
  "category": "Visserie",
  "featured": true
}

Neither is satisfactory.

The widget or the API

heurix-search.js is a ready-to-drop search bar. Heurix.searchBox() requires a DOM element (containerId), injects its styles and draws its own list. It reads name, ref, price and compare_at_price, so it needs full mode, and accepts renderItem, onSelect and resultHref for rendering and navigation. It suits a browser-rendered page that wants autocomplete without writing it. It does not run in a server render, it is not a React or Vue component, and no npm package is published.

A front end with its own components calls the API directly:

const r = await fetch("https://api.heurix.fr/v1/index/boutique/search", {
  method: "POST",
  headers: {
    "Authorization": "Bearer hxp_your_public_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ q, limit: 24, exclude_description: true }),
});
const { total, hits, facets, fallback } = await r.json();

A misnamed field in the body is rejected with a 422 rather than ignored: {"query": "écrou"} does not return the whole catalog, the response names the extra field.

Next steps

The three integration levels and the details of ids_only are in the documentation, creating and revoking public keys in Authentication, and the full parameter list in Search.

14-day free trial

Try Heurix on your catalog, no credit card.

Start free trial