← blog Read in English
IT 7 min read

Algolia su un e-commerce multi-brand: varianti, locale e replica index

Come ho strutturato l'indicizzazione Algolia per un e-commerce con più brand, varianti colore esplose, categorie gerarchiche, indici per lingua e replica per ordinamento.

algoliaecommercetypescriptsanity

Un e-commerce con più brand, lingue e varianti di prodotto pone problemi specifici al motore di ricerca: ogni brand ha logiche di ordinamento diverse, ogni mercato ha il suo indice, e la stessa maglia disponibile in 12 colori non dovrebbe apparire 12 volte nei risultati.

Ho costruito il layer di sincronizzazione Algolia per un frontend multi-brand su Sanity + Shopify. Ogni brand aveva la sua configurazione, ma condivideva la stessa infrastruttura Lambda e la stessa libreria di utilità.

Il problema delle varianti colore

Su Shopify, un prodotto con 5 colori × 3 taglie ha 15 varianti. Se indicizzi ogni variante come documento separato, una query su “maglione grigio” restituisce 3 risultati identici — uno per taglia.

La soluzione è distinct: true con attributeForDistinct: 'distinctId'. Algolia raggruppa i documenti con lo stesso distinctId e ne mostra solo il rappresentante canonico.

Il distinctId dipende dalla configurazione del prodotto:

const distinctId = `${product._id}${
  explodedVariants && variantOption ? ` - ${variantOption}` : ''
}`;
  • explodeColorVariantsInSearch: false — tutti i colori collassano su un unico documento; distinctId è solo l’ID prodotto.
  • explodeColorVariantsInSearch: true — ogni colore ha il suo documento; distinctId è productId - colore. Le 3 taglie del grigio collassano, ma il grigio e il blu restano separati.

Il flag è per prodotto, non globale. Un vestito multi-colore ha senso esploderlo; una borsa disponibile in nero e marrone no.

Il documento variante

Ogni variante in stock diventa un documento Algolia. Il documento contiene campi del prodotto (titolo, slug, categorie) e campi della variante (SKU, inventario, prezzo):

export const createSearchVariantDocument = ({
  product,
  variant,
  variantIndex,
  images,
}): Omit<ProductVariant, 'distinctId'> => {
  return {
    objectID: variant.id,
    productId: product._id,
    price: priceInMinorUnits(variant.price),
    compareAtPrice: priceInMinorUnits(variant.compareAtPrice),
    discount: calculateDiscountAmount({ price: variant.price, compareAtPrice: variant.compareAtPrice }),
    hasDiscount: discount > 0,
    slug: product.seo?.slug?.current,
    title: product.title,
    variantTitle: variant.title,
    sorting: variantIndex,
    categories: createCategoriesForSearch(product.categories),
    isCanonical: canonicalVariant?.id === variant.id,
  };
};

isCanonical merita attenzione: tra tutte le varianti di un prodotto, quella canonica è quella che ha uno sconto attivo e inventario disponibile — è quella che viene mostrata nel risultato di ricerca. Il customRanking la porta sempre in cima.

I prezzi sono in centesimi (price * 100). Algolia ordina per intero; 19.99 diventa 1999. Evita problemi di floating point nel ranking numerico.

Categorie gerarchiche: lvl0/lvl1/lvl2

Algolia supporta la navigazione gerarchica tramite campi categories.lvl0, categories.lvl1, categories.lvl2. Il formato atteso è:

categories.lvl0: ["Donna"]
categories.lvl1: ["Donna > Abbigliamento"]
categories.lvl2: ["Donna > Abbigliamento > Maglie"]

Il problema è che in Sanity le categorie sono documenti con riferimento al padre (parentPage). Ho scritto una funzione che percorre la catena verso l’alto e costruisce il path:

export const createCategoriesForSearch = (categories) => {
  const searchCategories = {};

  for (const category of categories) {
    const categoryPath = [category.title.trim()];
    let parentPage = category.parentPage;

    for (; parentPage?.title || parentPage?.parentPage; parentPage = parentPage?.parentPage) {
      categoryPath.unshift(parentPage?.title?.trim() || '');
    }

    const categoryPathForSearch = categoryPath.map((_, i) =>
      categoryPath.slice(0, i + 1).join(' > ')
    );

    for (let index = 0; index < categoryPathForSearch.length; index++) {
      const lvl = `lvl${index}`;
      searchCategories[lvl] = [
        ...new Set([...(searchCategories[lvl] || []), categoryPathForSearch[index]]),
      ];
    }
  }

  return searchCategories;
};

Stessa logica per le collezioni (collections.lvl0/1). Il facet filterOnly(categoriesId) permette al client di filtrare per ID senza che il campo sia searchable.

Famiglie di colore

Un prodotto disponibile in “celeste polvere”, “azzurro cielo” e “navy” ha 3 varianti colore. Il filtro per colore funziona se l’utente cerca “blu” — ma i nomi precisi non combaciano.

In Sanity ho un documento Color con un campo parent.name (la famiglia: Blu, Verde, Rosso). Al momento dell’indicizzazione mappa ogni colore specifico alla sua famiglia:

const colorFamilies = await getAllColorFamilies(sanityClient);
// { "celeste polvere": "Blu", "navy": "Blu", "rosso ciliegia": "Rosso", ... }

const colorFamilyVariants = uniqueArray(
  colorVariants.map(color => colorFamilies[color])
);

Il campo indicizzato è colorFamilyVariants: ["Blu"]. Il cliente filtra per famiglia, non per nome esatto.

Quando le varianti colore sono esplose, colorFamilyVariants contiene solo la famiglia del colore di quella variante — non tutte le famiglie del prodotto.

Multi-locale: un indice per lingua

Ogni brand è distribuito su più mercati (IT, DE, FR, EN). Algolia non supporta query multilingua su un unico indice — le stopword, lo stemming, e la tokenizzazione sono per lingua.

La convenzione degli indici è {indexName}_{locale} per le lingue non default:

export const getAlgoliaIndexLocalized = ({ indexName, locale }) => {
  const defaultLocale = getDefaultLocaleFromEnv();
  if (!locale || defaultLocale === locale) return indexName;
  return `${indexName}_${locale}`;
};
// "products" per it (default), "products_de" per de, "products_fr" per fr

Al momento della sync, il handler itera su tutti i locale disponibili, filtra i prodotti Sanity per lingua, e salva su indice separato:

for (const locale of getLocalesFromEnv()) {
  const products = sanityProducts.filter(
    product => product?.i18nLang === locale ||
      (!product?.i18nLang && locale === getDefaultLocaleFromEnv())
  );

  const algoliaIndex = getAlgoliaIndexLocalized({ indexName, locale });
  const productIndex = getAlgoliaClient().initIndex(algoliaIndex);

  await syncProductsWithAlgolia({ productIndex, products });

  await productIndex.setSettings({
    ...indexSettings,
    indexLanguages: [getLanguageTag(locale)],
    replicas: replicas.map(name => getAlgoliaIndexLocalized({ indexName: name, locale })),
  });
}

Le impostazioni (indexLanguages) vengono aggiornate ad ogni sync, non solo alla prima configurazione. Questo garantisce che una modifica alla configurazione dell’indice non richieda un passo manuale separato.

Replica index per ordinamento

Algolia permette un solo ordinamento primario per indice. Per supportare “ordina per prezzo crescente”, “ordina per sconto maggiore”, o “ordina per valutazione” ho usato gli indici replica — copie dell’indice principale con ranking diverso.

Ogni brand ha le sue repliche configurate in base alle feature del frontend:

// Brand fashion — solo prezzo
replicas: ['products_price_asc', 'products_price_desc'],

// Brand food — prezzo + valutazioni + sconto
replicas: [
  'products_price_asc',
  'products_price_desc',
  'products_higher_vote',
  'products_most_comments',
  'products_higher_discount',
],

// Brand baby — prezzo + sconto
replicas: [
  'products_price_asc',
  'products_price_desc',
  'products_higher_discount',
],

Le repliche vengono create e configurate automaticamente dalla libreria Algolia quando si fa setSettings con replicas sull’indice principale. Il client si connette alla replica quando l’utente seleziona un ordinamento diverso dal default.

Il flusso di sync

Il trigger è un webhook Sanity (POST con firma HMAC). Il Lambda:

  1. Verifica la firma
  2. Ricava l’_id del documento dal body (se presente, sync singolo; altrimenti sync totale)
  3. Interroga Sanity via GraphQL con filtro su is_draft: false
  4. Per ogni locale: filtra prodotti, ottiene indice localizzato, salva oggetti, aggiorna settings
const { _id: productId } = useJsonBody() || {};

if (productId && !isValidSignature()) {
  return error({ message: 'Invalid signature', statusCode: 401 });
}

const response = await client.request(GQL_QUERY, {
  where: {
    _: { is_draft: false },
    ...(productId && { _id: { eq: productId } }),
  },
});

Il sync totale cancella l’indice prima di reinserire (productIndex.clearObjects()). Il sync singolo aggiorna o inserisce senza cancellare — Algolia gestisce l’upsert tramite objectID.

Recupero hits con protezione dal loop infinito

Per leggere tutti i record dall’indice (usato per reportistica e riconciliazione) ho una utility con paginazione e protezione contro loop:

const MAX_PAGES_PREVENT_LOOP = 50;

for (
  let currentPage = 0, totalPages = 1, loopIndex = 0;
  currentPage <= totalPages && loopIndex < MAX_PAGES_PREVENT_LOOP;
  currentPage++, loopIndex++
) {
  if (loopIndex === MAX_PAGES_PREVENT_LOOP - 1) {
    return Promise.reject(new Error(`Reached max pages: ${MAX_PAGES_PREVENT_LOOP}`));
  }

  const { hits, page, nbPages } = await fetchAlgoliaHits({ algoliaIndexName, page: currentPage, hitsPerPage: 1000 });
  allHits = [...allHits, ...hits];
  totalPages = nbPages;
  currentPage = page;
}

hitsPerPage: 1000 è il massimo consentito da Algolia. Con 50 pagine massimo, si coprono fino a 50.000 record — sufficiente per qualsiasi catalogo e-commerce realistico.

Cosa rifarei uguale, cosa no

La struttura con explodeColorVariantsInSearch funziona bene. Dare a ogni prodotto la scelta su come gestire le varianti evita compromessi: i prodotti con varianti visivamente rilevanti (abbigliamento) hanno la flessibilità di esploderle, quelli senza no.

Le famiglie di colore in Sanity sono il posto giusto. Mantenerle nel CMS significa che il merchandising può aggiungerle senza toccare il codice.

La localizzazione via indice separato scala, ma aumenta i costi Algolia linearmente con il numero di mercati. Su un catalogo con 4 lingue e 5 repliche sono 20 indici per brand. Con più brand, i costi diventano significativi.

Il sync totale che cancella prima di reinserire è fragile. Se il Lambda fallisce a metà, l’indice è vuoto. Un approccio migliore sarebbe confrontare gli ID presenti e cancellare solo quelli rimossi — ma richiede più logica.