AACsearch
SDKsCookbook

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.

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:

  1. Add the locale field to the schema (it is non-breaking).
  2. Backfill existing documents with locale: "en".
  3. Update the connector to emit per-locale rows.
  4. Roll out scoped tokens with the locale filter to clients.
  5. 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.

On this page