Recherche headless : ce que la réponse porte, et l'URL que votre front compose
Un front headless n'a pas de thème pour afficher un résultat : il reçoit des données et construit la page lui-même. Il doit donc savoir exactement ce que la recherche lui rend, et ce qu'il devra aller chercher ailleurs. Voici la réponse de Heurix champ par champ, avec des appels réels, et les limites qu'un intégrateur rencontre dès le premier jour.
Les réponses reproduites ici sont celles du moteur dans la version servie en production le 13 septembre 2026, sur un catalogue de trois produits indexé pour l'occasion : deux variantes d'une même vis et un écrou.
Un appel, le produit entier
Par défaut, chaque résultat porte le produit complet tel que vous l'avez indexé. Le choix est délibéré : le client n'a jamais à faire un second appel pour afficher une carte de résultat. Pour un front headless, c'est un aller-retour réseau de moins par recherche.
curl -X POST https://api.heurix.fr/v1/index/boutique/search \
-H "Authorization: Bearer hxp_votre_cle_publique" \
-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
}
Le produit indexé portait aussi un champ margin. Il n'est pas dans la réponse : une clé publique ne le reçoit jamais. Voici d'où vient chaque champ.
| Champ | D'où il vient |
|---|---|
product.* | Votre dernier envoi d'indexation, tel quel. Rien n'est relu sur votre plateforme au moment de la recherche. |
product.price | Le champ de prix que sert la clé appelante : price par défaut. |
product.margin | Retiré pour une clé publique, servi à une clé serveur. |
scorematched | Calculés par cette recherche : la pertinence, et ses raisons lisibles. |
in_stock | Déduit de votre champ stock. |
pinnedburied | Les priorités de requête posées dans la console. |
fallback | true quand hits contient vos produits featured faute de résultat ; total reste alors à 0. |
facetsprice_filterfilters_unknownsuggested_categoryhighlighted_bundle | Présents seulement quand ils ont quelque chose à dire. Leur absence est le cas normal. |
Ce que la réponse ne porte pas, sauf si vous l'avez indexé : l'URL de la fiche, l'image, les variantes et leurs options, la devise, le stock et le prix en temps réel. Un prix changé dans votre back-office n'existe pour la recherche qu'à votre prochain envoi. Le poids, lui, se règle avec exclude_description : sur un catalogue réel, la description représente plus de 80 % du poids d'un produit dans la réponse.
Le moteur ne compose jamais d'URL, le front la compose
Dans la réponse ci-dessus, url est présent parce qu'il a été indexé. La seconde variante de la même vis n'en a pas, et le moteur n'en fabrique pas : il ne connaît ni votre domaine, ni votre canal de vente, ni vos routes.
C'est ce qui rend la réponse utilisable sans thème. Indexez l'élément stable, un handle ou un slug, et composez le chemin côté front :
const lien = p.url || "/products/" + encodeURIComponent(p.handle);
Un chemin relatif reste juste sur le domaine de production, sur localhost et sur un domaine de prévisualisation. C'est la règle qu'applique notre propre script de vitrine Shopify : une boutique protégée par mot de passe, l'état de tout marchand qui prépare son ouverture, ne fournit pas d'URL publique de produit, et le chemin se reconstruit depuis le handle.
ids_only : quand votre plateforme hydrate ses fiches
Si votre front lit déjà ses produits ailleurs, dans un CMS, un PIM ou l'API de votre plateforme, demandez seulement l'ordre :
{"q": "inox m8", "ids_only": true}
{"total": 3, "ids": ["44556677889903", "44556677889901", "44556677889902"]}
id_field renvoie un autre champ à la place de id : l'identifiant qu'attend votre plateforme, un handle, un 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"]}
Trois identifiants, deux distincts. Un catalogue indexé par variante renvoie le handle du produit une fois par variante trouvée : dédoublonnez avant d'hydrater, en gardant la première occurrence pour conserver l'ordre. Un produit sans le champ demandé est écarté, et compté :
{"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"}
Ce que ids_only laisse de côté
La réponse légère ne porte que total et ids. Mesuré sur le même catalogue :
- Les facettes demandées sont absentes, sans avertissement. La même requête en mode complet rend
"facets": {"FAM": {"FAM_VIS": 2, "FAM_ECROU": 1}}. - Zéro résultat rend
{"total": 0, "ids": []}. Le mode complet rend vos produitsfeaturedavec"fallback": true. price_filter,filters_unknownetmatchedn'apparaissent pas non plus.
Si votre page de résultats affiche des facettes, le mode complet avec exclude_description reste l'appel à faire.
La clé publique, et ce qu'impose allowed_origins
Un front headless appelle depuis le navigateur, avec une clé publique hxp_ créée par votre clé serveur. Elle cherche, parcourt un catalogue et remonte des événements, rien d'autre : une clé publique qui tente d'indexer reçoit un 403. Son quota est celui de la clé serveur qui l'a créée.
curl -X POST https://api.heurix.fr/v1/keys/public \
-H "Authorization: Bearer VOTRE_CLE_SERVEUR" \
-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"}
La comparaison porte sur l'hôte seul : le schéma et le port sont retirés, à l'écriture comme à chaque requête. Trois conséquences, mesurées :
- localhost est accepté sur tous les ports. Déclaré
localhost:3000, il laisse aussi passer une originehttp://localhost:5173. - Pas de joker.
*.boutique.exampleest refusé en 422, et*aussi. Un champ vide est la seule façon d'autoriser toutes les origines. - Chaque domaine de prévisualisation est un hôte distinct, donc refusé.
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 et Netlify créent un domaine par déploiement, que la liste ne peut pas énumérer. Aujourd'hui, un environnement de prévisualisation n'a qu'une issue : une clé publique sans restriction d'origine, utilisable depuis n'importe quel site tant qu'elle existe.
Rendu côté serveur : aujourd'hui, deux options, aucune satisfaisante
Un rendu côté serveur appelle depuis Node, pas depuis un navigateur : la requête ne porte pas d'en-tête Origin, et une clé publique restreinte la refuse.
HTTP 403
{"detail": "Origine 'absente' non autorisée pour cette clé publique. Domaines autorisés : www.boutique.example, boutique.example, localhost."}
Deux options existent, et chacune a un coût.
- Une clé publique sans restriction d'origine. Le serveur l'appelle sans difficulté. Mais la restriction tombe pour tout le monde : si cette clé est aussi servie au navigateur, n'importe quel site peut l'utiliser, sur votre quota.
- La clé serveur. Elle n'a pas besoin d'
Origin, mais elle voit le catalogue entier : la marge sort dans la réponse, et le prix configuré sur une clé publique ne s'applique pas. Tout ce que votre rendu transmet au navigateur pour l'hydratation emporte ces champs.
Le même produit, pour la même requête, selon la clé :
// clé serveur
{
"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
}
// clé publique
{
"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
}
Aucune des deux n'est satisfaisante.
Le widget ou l'API
heurix-search.js est une barre de recherche prête à poser. Heurix.searchBox() exige un élément du DOM (containerId), injecte ses styles et dessine sa propre liste. Il lit name, ref, price et compare_at_price, donc le mode complet, et accepte renderItem, onSelect et resultHref pour le rendu et la navigation. Il convient à une page rendue dans le navigateur qui veut une autocomplétion sans l'écrire. Il ne tourne pas dans un rendu serveur, ce n'est pas un composant React ou Vue, et aucun paquet npm n'est publié.
Un front qui a ses propres composants appelle l'API directement :
const r = await fetch("https://api.heurix.fr/v1/index/boutique/search", {
method: "POST",
headers: {
"Authorization": "Bearer hxp_votre_cle_publique",
"Content-Type": "application/json",
},
body: JSON.stringify({ q, limit: 24, exclude_description: true }),
});
const { total, hits, facets, fallback } = await r.json();
Un champ mal nommé dans le corps est refusé en 422 plutôt qu'ignoré : {"query": "écrou"} ne renvoie pas le catalogue entier, la réponse nomme le champ en trop.
Pour la suite
Les trois niveaux d'intégration et le détail de ids_only sont dans la documentation, la création et la révocation des clés publiques dans Authentification, et la liste complète des paramètres dans Recherche. Pour servir un prix différent par population de clients, voir le prix par clé publique.