Multi-locale catalog
One product, multiple locales. Single index with a `locale` facet, locale-specific text fields, and a scoped token that pins the user to their language.
A storefront in 5 languages can be modelled three ways: separate indexes per locale, separate documents per locale in one index, or a single document with locale-keyed fields. This recipe uses the second pattern — separate documents per locale, one shared index — because it gives the cleanest search semantics and the simplest scoring.
Why not "one document with all locales"
// Antipattern
{
id: "product-123",
title_en: "Running shoes",
title_de: "Laufschuhe",
title_fr: "Chaussures de course",
title_es: "Zapatillas de correr",
title_ru: "Беговые кроссовки",
}It seems compact, but you have to queryBy: "title_en,title_de,title_fr,title_es,title_ru" on every search — which fragments scoring across 5 fields and matches French queries against German titles. Bad results.
The right shape
One document per (product × locale):
type LocalizedProduct = {
id: string; // composite: <external_id>-<locale>
external_id: string;
locale: "en" | "de" | "es" | "fr" | "ru";
title: string;
description: string;
brand: string;
categories: string[]; // also localized
price: number;
currency: "USD" | "EUR" | "RUB";
in_stock: boolean;
};Index schema:
await admin.createIndex({
slug: "products",
fields: [
{ name: "title", type: "string", locale: "auto" },
{ name: "description", type: "string", locale: "auto" },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "locale", type: "string", facet: true },
{ name: "price", type: "float", facet: true },
{ name: "currency", type: "string", facet: true },
{ name: "in_stock", type: "bool", facet: true },
{ name: "external_id", type: "string", facet: true }, // for grouping
],
defaultSortingField: "price",
});locale: "auto" enables per-locale tokenization (German compound words, Russian stemming, etc.).
Search filtered by locale
Every search must filter by the user's locale, otherwise they see results in 5 languages mixed:
import { useLocale } from "next-intl";
const locale = useLocale(); // "en" | "de" | "es" | "fr" | "ru"
const result = await client.search({
q: searchQuery,
queryBy: "title,description,brand",
filterBy: `locale:=${JSON.stringify(locale)}`,
facetBy: "brand,categories,price",
perPage: 24,
});This works, but every caller has to remember the filter. The next section eliminates that risk.
Locale-pinned scoped tokens (recommended)
Mint a scoped token per locale on the server. The token AND-combines locale so the client cannot accidentally search across all locales:
// app/api/search-token/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { orpc } from "@/lib/orpc";
const SUPPORTED_LOCALES = ["en", "de", "es", "fr", "ru"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
function detectLocale(): Locale {
// Read from cookie, header, or path — your i18n strategy
const fromHeader = headers().get("Accept-Language")?.split(",")[0].split("-")[0] as Locale;
return SUPPORTED_LOCALES.includes(fromHeader) ? fromHeader : "en";
}
export async function POST() {
const locale = detectLocale();
const token = await orpc.search.createScopedToken.call({
organizationId: process.env.AACSEARCH_ORG_ID!,
indexSlug: "products",
scopedFilter: `locale:=${locale}`,
expiresInSeconds: 4 * 60 * 60,
name: `i18n-${locale}`,
});
return NextResponse.json({ token: token.token, locale });
}Now the client has nothing to remember:
const { token, locale } = await fetch("/api/search-token", { method: "POST" }).then((r) =>
r.json(),
);
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: token,
indexSlug: "products",
});
// All searches automatically scoped to the user's locale
const result = await client.search({ q: "shoes", queryBy: "title" });Currency and price
Each locale's documents carry their own price and currency. Display logic stays simple:
function formatPrice(product: LocalizedProduct) {
return new Intl.NumberFormat(product.locale, {
style: "currency",
currency: product.currency,
}).format(product.price);
}For RU, this returns "1 490 ₽". For EN, "$19.99". Intl.NumberFormat does the heavy lifting.
Connector: produce one document per (external_id × locale)
In your connector, fan out each product across locales:
async function syncProduct(source: SourceProduct) {
const docs = SUPPORTED_LOCALES.flatMap((locale) => {
const t = source.translations[locale];
if (!t) return []; // skip locales with no translation
return [
{
id: `${source.id}-${locale}`,
external_id: source.id,
locale,
title: t.title,
description: t.description,
brand: source.brand, // brand is locale-independent
categories: t.categories,
price: source.prices[currencyForLocale(locale)],
currency: currencyForLocale(locale),
in_stock: source.in_stock,
},
];
});
await fetch(`${BASE}/api/projects/${ORG}/sync/delta`, {
method: "POST",
headers: { Authorization: `Bearer ${CONNECTOR_TOKEN}` },
body: JSON.stringify({ products: docs }),
});
}If a product has translations in only 3 of 5 locales, it appears only in those 3 locale results. No fallback to English by default — you have to explicitly produce an EN row to make it discoverable in EN search.
Cross-locale URL canonicalization
When the user clicks a result, you usually want the URL to point at the same product on the locale-prefixed path:
function productUrl(product: LocalizedProduct) {
return `/${product.locale}/products/${product.external_id}`;
}The external_id is shared across locales — that is what lets you map "the same product" between languages.
Migrating from a single-locale index
If you started with EN only and need to add more languages:
- Add the
localefield to the schema (it is non-breaking). - Backfill existing documents with
locale: "en". - Update the connector to emit per-locale rows.
- Roll out scoped tokens with the locale filter to clients.
- Once all clients use the new tokens, you are isolated.
The transition is zero-downtime — the EN-only index remains queryable throughout.
For ~5 locales and a typical catalog, this multiplies document count by 5×. AACsearch search-units count one per indexed document — factor that into your plan choice.
Related
- Scoped search tokens — locale enforcement
- Marketplace tenant isolation — same pattern, different filter
- Connector API lifecycle — pushing localized documents
Marketplace tenant isolation
Multi-vendor marketplace where each storefront sees only its own SKUs. Single index, vendor-scoped tokens, zero cross-tenant data leakage.
Server-side helpers
Batching, idempotency, retry strategy, and webhook signature verification — the patterns every server-side SDK consumer should follow when ingesting documents and receiving webhooks from AACsearch.