# Multi-language storefronts

Your scaffolded Brainerce store is already wired for multi-language out of the box. This doc explains the moving parts so you can customize them — you do **not** need to write per-locale fetch code; the SDK + middleware do it for you.

## Status check

```typescript
const store = await client.getStoreInfo();
store.i18n?.enabled; // → true / false
store.i18n?.defaultLocale; // → e.g. "en"
store.i18n?.supportedLocales; // → e.g. ["en", "he"]
```

If `i18n.enabled` is `false` or only one locale is supported, the rest of this doc is a no-op — the app behaves as a single-language store.

## URL strategy: "as-needed" locale prefix

This template uses the as-needed pattern (the most common approach for SEO):

| URL            | Locale  | Notes                                             |
| -------------- | ------- | ------------------------------------------------- |
| `/`            | default | Clean URL — no `/en` prefix on the default locale |
| `/products`    | default | Same                                              |
| `/he`          | Hebrew  | Secondary locales get a path prefix               |
| `/he/products` | Hebrew  | Same                                              |

The middleware (`src/middleware.ts`) handles two transitions:

1. `/{defaultLocale}/X` → 308 redirect to `/X` (canonicalize away the redundant prefix)
2. `/X` → internal rewrite to `/{defaultLocale}/X` so the Next.js `[locale]` route segment still resolves

Every response carries an `x-locale` header so Server Components can read the resolved locale via `headers()`.

## How translated content shows up on the page

You write **one** `fetch` and it works for every language.

```tsx
// src/app/[locale]/products/[slug]/page.tsx
import { getServerClient } from '@/core/lib/server-client';
import { headers } from 'next/headers';

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const locale = (await headers()).get('x-locale') ?? undefined;
  const client = getServerClient();
  client.setLocale(locale);

  const product = await client.getProductBySlug(slug);
  // product.name, product.description, product.categories[].name, modifier groups,
  // metafield labels — all already translated by the server.

  return <ProductDetail product={product} />;
}
```

The `StoreProvider` (`src/providers/store-provider.tsx`) calls `client.setLocale(locale)` on the client side, so React Server Components and Client Components both get translated content.

## What's translatable (full list)

| Entity                  | Fields                                                      |
| ----------------------- | ----------------------------------------------------------- |
| **Product**             | `name`, `description`, `slug`, `seoTitle`, `seoDescription` |
| **ProductVariant**      | `name`                                                      |
| **Category**            | `name`                                                      |
| **Brand**               | `name`                                                      |
| **Tag**                 | `name`                                                      |
| **Attribute**           | `name` (e.g. "Color")                                       |
| **AttributeOption**     | `name` (e.g. "Red")                                         |
| **ModifierGroup**       | `name`, `description` (e.g. "Toppings" → "תוספות")          |
| **Modifier**            | `name`, `description` (e.g. "Olives" → "זיתים")             |
| **ProductMetafield**    | `value` (free-text custom field values)                     |
| **MetafieldDefinition** | `name`, `description` (custom-field labels)                 |
| **BundleOffer**         | `name`, `description` (bundle marketing label)              |
| **OrderBumpConfig**     | `title`, `description` (bump headline at checkout)          |
| **DiscountRule**        | `name`, `description` (rule label, used in banners)         |
| **ContactForm**         | `name`, `description`, `submitButton`, `successMessage`     |
| **ContactFormField**    | `label`, `placeholder`, `helpText`                          |

You never overlay translations yourself — the SDK does it on every request.

## RTL (Hebrew, Arabic, Persian, Urdu, Yiddish)

`src/i18n.ts` exports `getDirection(locale)` that delegates to the SDK's `getDirectionForLocale()`. The layout uses it on `<html dir={…}>`:

```tsx
// src/app/[locale]/layout.tsx
import { getDirection } from '@/i18n';

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;
  const dir = getDirection(locale);
  return (
    <html lang={locale} dir={dir}>
      <body>{children}</body>
    </html>
  );
}
```

This automatically reverses flexbox row order — **do not add `flex-row-reverse`** on top, that's a double-swap. **Do** swap directional icons (chevrons, arrows) using `useDirection()` from `@radix-ui/react-direction`.

Use logical Tailwind classes (`ms-*`/`me-*` for margin, `ps-*`/`pe-*` for padding, `start-*`/`end-*` for positioning) instead of physical ones (`ml-*`, `mr-*`, `left-*`, `right-*`) so the layout mirrors automatically.

## Language switcher

```tsx
'use client';
import { useStore } from '@/providers/store-provider';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';

export function LanguageSwitcher() {
  const { storeInfo } = useStore();
  const pathname = usePathname();
  const { locale: current } = useParams<{ locale?: string }>();

  if (!storeInfo?.i18n?.enabled) return null;
  const locales = storeInfo.i18n.supportedLocales;
  const defaultLocale = storeInfo.i18n.defaultLocale;

  return (
    <nav className="flex gap-2">
      {locales.map((loc) => {
        const isCurrent = (current ?? defaultLocale) === loc;
        const href = loc === defaultLocale ? pathname : `/${loc}${pathname}`;
        return (
          <Link key={loc} href={href} className={isCurrent ? 'font-bold' : ''}>
            {loc.toUpperCase()}
          </Link>
        );
      })}
    </nav>
  );
}
```

The merchant configures supported locales in `Dashboard → Settings → Languages`. Your switcher reads them from `storeInfo.i18n.supportedLocales` — never hardcode a list.

## SEO: per-locale slugs and hreflang

When the merchant translates a product's `slug`, every locale gets its own URL (e.g. `/cheese-pizza` and `/he/פיצה-גבינה`). Pull all alternates in one call for the `<head>`:

```tsx
const alternates = await client.getProductAlternates(product.id);
// → [{ locale: 'en', slug: 'cheese-pizza' }, { locale: 'he', slug: 'פיצה-גבינה' }]

// In generateMetadata:
return {
  alternates: {
    languages: Object.fromEntries(
      alternates.map((a) => [a.locale, `/${a.locale}/products/${a.slug}`])
    ),
  },
};
```

## Promotional surfaces: bundles, bumps, discount banners

The same overlay applies to every promotional surface, so you don't need locale-aware code:

```tsx
// Cart bundles (cross-sell), locale-aware automatically:
const cart = await client.getCart(cartId);
cart.bundles[0].name; // "ארוחת צהריים" (bundle's own label)
cart.bundles[0].offeredProducts[0].name; // "פיצה גבינה" (each offered product)

// Order bumps at checkout. `getCheckoutBumps` takes a CHECKOUT id, not a cart id:
const { bumps } = await client.getCheckoutBumps(checkoutId);
bumps[0].title; // translated bump headline (or merchant override)
bumps[0].bumpProduct.name; // translated product name

// Adding or removing a bump takes the CART id plus the bump config id
// (bumps[i].id). Pass a variantId when bumps[i].requiresVariantSelection:
await client.addOrderBump(cartId, bumps[0].id, selectedVariantId);
await client.removeOrderBump(cartId, bumps[0].id);

// Discount-rule banners. The API returns ready-to-render banner text, not the
// rule object: DiscountBanner is { ruleId, text, type }, so there is no `name`
// and no `displayConfig` to compose yourself.
const banners = await client.getDiscountBanners();
banners[0].text; // translated banner copy
```

## How merchants populate translations

For background — your storefront doesn't need to call these endpoints, but knowing the merchant flow helps when debugging unexpectedly-empty translations:

- **Per-row overlay** on every taxonomy/product list page in the dashboard.
- **Inside the entity create/edit modal** — a `LocaleSelector` in the header + "Translate with AI" button populates target-locale fields (Products, Attributes, Modifier Groups, Modifiers, Custom Fields).
- **Translate icon button** on bundle / order-bump rows (`/products/.../offers`) and on discount-rule rows (`/discount-rules`) opens a standalone translation modal with one-click AI.
- **Bulk** — select N rows on a list page → toolbar action "Translate to Hebrew" enqueues an AI translation job for everything selected (including children, e.g. all modifiers under selected groups).

Translations are persisted via the dashboard-only endpoint `PUT /api/stores/:storeId/translations/:entityType/:entityId/:locale`. The storefront SDK never calls this — it only consumes the overlay on read.

## Troubleshooting

| Symptom                                                        | Likely cause                                        | Fix                                                                               |
| -------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------- |
| `product.name` is English even though locale is `he`           | Store doesn't have `he` in `supportedLocales`       | Add the locale in `Dashboard → Settings → Languages`                              |
| Some fields translate, others don't                            | Merchant translated subset                          | Per-field fallback is by design — fill the rest in dashboard                      |
| Layout broken in Hebrew                                        | Missing `<html dir="rtl">`                          | Use `getDirection(locale)` in the layout                                          |
| Modifier names in default language but product name translates | Merchant translated `Product` only                  | Bulk translate on `/products/modifier-groups` covers groups + all their modifiers |
| Custom-field label "Warranty" doesn't translate                | Merchant didn't translate the `MetafieldDefinition` | Per-row "Translate" on `/products/custom-fields`                                  |
| Bundle name "Summer Sale" stays in English in Hebrew cart      | Merchant didn't translate the `BundleOffer` itself  | Click the Languages icon on the bundle row in `/products/.../offers`              |

See [the Brainerce docs](https://brainerce.com/docs/concepts/translations) for the canonical reference.
