Developer Demo

Localization & Multi-Language

How Optimizely CMS stores content per locale, how Graph returns locale-specific variants, and how Next.js routes requests to the right language.

Locale as a dimension, not a copy#

The CMS does not create a separate content item for each language. Instead, locale is a dimension on a single content item - the same content key resolves to different field values depending on which locale is requested. This means editors translate content in-place inside Visual Builder without creating duplicates, and a content update doesn't require translating every language before it can be published.

Same key, different locale variants in Graph
# The CMS stores content per locale as a dimension, not as separate copies.
#
# When an editor opens a page in Visual Builder and switches locale,
# they are editing the same content item - just its locale variant.
# The same content key (e.g. "abc123") resolves to different field values
# depending on the locale requested.
#
# Graph exposes this through _metadata.locale:
query GetAllLocales {
  _Content(limit: 5) {
    items {
      _metadata {
        key           # same key across all locales
        locale        # "en", "fr", "de", etc.
        displayName   # locale-specific title
        url { default }
      }
    }
  }
}

# Result - same page key, two locale variants:
# { key: "abc123", locale: "en", displayName: "About Us", url: "/en/about" }
# { key: "abc123", locale: "fr", displayName: "À propos", url: "/fr/about" }

Same content key

All locale variants share the same key in the CMS. Update the structure once and all languages inherit the change.

Independent publication

Each locale variant can be drafted and published independently. English can go live while French is still in review.

Selective translation

If a locale hasn't been translated, Graph returns nothing for that key+locale combination - you fall back to the default locale.

Filtering by locale in Graph#

Add _metadata: { locale: { in: $locale } } to any query to get content in a specific language. Pass the locale from the request - URL segment, header, or cookie. Without a locale filter, Graph returns all locale variants for a given URL, which means you'd get both the English and French version of a page in one response. SDK docs ↗

Locale filter in a page query
# Add a locale filter to any query to get content in a specific language.
# Use the _metadata.locale field with the 'eq' or 'in' operator.

query GetLocalizedPage($url: String!, $locale: [Locales]) {
  _Content(
    where: {
      _metadata: {
        url:    { default: { eq: $url } }
        locale: { in: $locale }          # e.g. ["fr", "fr-CA"]
      }
    }
  ) {
    items {
      _metadata { locale displayName url { default } }
      ... on LandingPage {
        headline
        body { json }
      }
    }
  }
}

# In graphqlFetch - pass locale from the request:
const locale = params.lang ?? "en";
const res = await graphqlFetch(GET_LOCALIZED_PAGE, { url, locale: [locale] });

Next.js locale routing patterns#

Two common approaches: a [lang] URL segment (/en/about, /fr/about) or a subdomain per locale (fr.site.com/about). The segment approach is simpler - all routing is in one Next.js app, the locale is always visible in the URL (good for SEO), and hreflang links are easy to generate. Subdomains are better when different locales need separate deployments or CDN regions.

Option A - URL segment (recommended)

src/app/[lang]/[[...slug]]/page.tsx
// Option A - [lang] URL segment (recommended for most projects)
// URL structure: /en/about, /fr/about, /de/about
// The lang param is always explicit in the URL - no ambiguity.

// src/app/[lang]/[[...slug]]/page.tsx
export default async function Page({ params }) {
  const { lang, slug } = params;
  const url = `/${slug?.join("/") ?? ""}`;

  const res = await graphqlFetch(GET_LOCALIZED_PAGE, {
    url: `/${lang}/${slug?.join("/")}`,
    locale: [lang],
  });
  return <OptimizelyComponent content={res.data._Content.items[0]} />;
}

// Middleware generates the lang segment from Accept-Language:
// src/middleware.ts
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (pathname === "/" || !pathname.match(/^/(en|fr|de)/)) {
    const lang = request.headers.get("Accept-Language")?.split(",")[0]?.split("-")[0] ?? "en";
    return NextResponse.redirect(new URL(`/${lang}${pathname}`, request.url));
  }
}

Option B - Subdomain routing

src/middleware.ts - hostname-based locale detection
// Option B - Subdomain per locale
// URL structure: en.site.com/about, fr.site.com/about
// Requires middleware to read the hostname and map it to a locale.

// src/middleware.ts
export function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") ?? "";
  const locale   = hostname.startsWith("fr.") ? "fr"
                 : hostname.startsWith("de.") ? "de"
                 : "en";

  const res = NextResponse.next();
  res.headers.set("x-locale", locale);  // pass to server component via header
  return res;
}

// Then read it in the page:
import { headers } from "next/headers";
const locale = (await headers()).get("x-locale") ?? "en";

Fallback locale chains#

Not all content will be translated into every locale. A fallback chain ensures users always see something rather than a blank page or 404. Pass an ordered array of locales to Graph's in: filter - most specific first, default locale last. Graph returns the first match.

Fallback chain: fr-CA → fr → en
# Fallback locale chain - request the most specific locale first,
# then progressively broader locales, then the default.
#
# If a page hasn't been translated into fr-CA yet, the fr version
# (or the en fallback) should be shown instead of a 404.
#
# Graph returns the first match from the array - ordered by specificity.

query GetPageWithFallback($url: String!) {
  _Content(
    where: {
      _metadata: {
        url:    { default: { eq: $url } }
        locale: { in: ["fr-CA", "fr", "en"] }   # ← priority order
      }
    }
    limit: 1   # only want the best match
  ) {
    items {
      _metadata { locale displayName url { default } }
      ... on LandingPage { headline body { json } }
    }
  }
}

// Build the fallback chain from the request locale in code:
function buildLocaleChain(locale: string): string[] {
  const [lang, region] = locale.split("-");
  const chain = region ? [`${lang}-${region}`, lang] : [lang];
  if (!chain.includes("en")) chain.push("en");  // always fall back to default
  return chain;
}
// buildLocaleChain("fr-CA") → ["fr-CA", "fr", "en"]

Mixed-locale pages - CMS content vs. UI strings#

A localized page has two sources of translated text. Body content - headlines, copy, FAQs - comes from the CMS and is translated by editors. UI strings - button labels, form placeholders, navigation items - are owned by the app and live in translation files managed by developers. Keep these separate: don't store UI strings in the CMS (they change on deploy, not on publish) and don't hard-code body copy in the app.

Two translation sources in one layout
// Headless CMS + i18n: two separate translation sources
//
// CMS content:    body copy, headlines, page content → translated in CMS per-locale
// UI strings:     button labels, navigation, error messages → translated by the app
//
// Keep them separate. Don't store UI strings in the CMS - they change at deploy
// time, not at publish time, and they shouldn't require a CMS publish to update.

// src/app/[lang]/layout.tsx
import { getTranslations } from "next-intl/server";  // or i18next, etc.

export default async function Layout({ children, params }) {
  const t = await getTranslations({ locale: params.lang });

  return (
    <html lang={params.lang}>
      <body>
        <nav>
          <a href={`/${params.lang}`}>{t("nav.home")}</a>      {/* UI string */}
          <a href={`/${params.lang}/contact`}>{t("nav.contact")}</a>
        </nav>
        <main>{children}</main>   {/* CMS content rendered here */}
      </body>
    </html>
  );
}

// The CMS page component gets its content from Graph with the locale filter -
// body copy, hero headline, FAQs - all from the CMS per-locale.
// The layout chrome (nav labels, footer links) comes from the app's i18n files.
generateStaticParams for multi-locale routes
// generateStaticParams for multi-locale pages
// Enumerate all locale + slug combinations to pre-render at build time.

export async function generateStaticParams() {
  const res = await graphqlFetch(GET_ALL_PAGE_PATHS_QUERY);
  const pages = res.data._Page.items ?? [];

  return pages
    .filter((p) => p._metadata?.url?.default && p._metadata?.locale)
    .map((p) => {
      const url    = p._metadata.url.default;
      const locale = p._metadata.locale;        // "en", "fr", etc.
      const slug   = url.replace(`/${locale}/`, "").split("/").filter(Boolean);
      return { lang: locale, slug };
    });
}

Key Things to Know#

  • Locale is a dimension, not a copy. The same content key has multiple locale variants. Editors translate in-place - no duplicate content items to keep in sync.
  • Always add a locale filter to Graph queries. Without it you get all locale variants in one response. Filter by _metadata.locale using the locale from the request.
  • Use an ordered fallback chain. Graph returns the first match from an in: array. Put the most specific locale first ("fr-CA"), then the language ("fr"), then the default ("en").
  • URL segment routing is simpler than subdomains. The lang param is available in server components without middleware header reads, and hreflang/canonical URLs are straightforward to generate.
  • Separate CMS content from UI strings. Body copy belongs in the CMS (translated by editors). Button labels and nav items belong in translation files (deployed with the app). Don't mix them.
  • Include locale in generateStaticParams to pre-render all language variants at build time. Each lang+slug combination is a separate static route.