Localization is one of the first things that gets complicated the moment a CMS project scales beyond a single market. Labels, error messages, button text, and other repeatable strings need to live somewhere content editors can manage them — without developers hardcoding strings into components or duplicating them across languages.
Optimizely SaaS CMS doesn’t ship with an out-of-the-box dictionary concept — but the same outcome is achievable by modeling a dedicated content type and wiring it into your Next.js frontend via the Content JS SDK — using next-intl to handle the actual localization plumbing on the frontend.
This is Part 1 of a two-part series:
- Part 1 (this post): Setting up a Dictionary content type in Optimizely SaaS CMS and retrieving/rendering dictionary values on the frontend using the Content JS SDK and next-intl.
- Part 2 (next post): Creating and managing dictionary items as an editor — content authoring workflow, key/value patterns, and best practices for scale.
Modeling the Content Types
Since Optimizely SaaS CMS has no built-in dictionary concept, we model one using two content types: DictionaryFolder and DictionaryItem.
DictionaryFolder — organizing items in the content tree.
import { contentType } from '@optimizely/cms-sdk';
import { DictionaryItemCT } from '../component/DictionaryItem';
export const DictionaryFolderCT = contentType({
key: 'DictionaryFolder',
displayName: 'Dictionary folder',
baseType: '_folder',
mayContainTypes: [DictionaryItemCT, '_self'],
});
DictionaryFolder gives editors a place in the CMS tree to group DictionaryItem entries. mayContainTypes allows DictionaryItemCT (items live inside) and _self (folders can nest, for organizing by feature or page).
DictionaryItem — the atomic translation unit.
import { contentType } from '@optimizely/cms-sdk';
export const DictionaryItemCT = contentType({
key: 'DictionaryItem',
displayName: 'Dictionary item',
baseType: '_component',
properties: {
Namespace: {
type: 'string',
displayName: 'Namespace',
description: 'Top-level message group, e.g. "common" or "home"',
isRequired: true,
group: 'Content',
sortOrder: 0,
format: 'shortString',
},
Key: {
type: 'string',
displayName: 'Key',
description: 'Message key within the namespace, e.g. "readMore"',
isRequired: true,
group: 'Content',
sortOrder: 10,
format: 'shortString',
},
Value: {
type: 'string',
displayName: 'Value',
description: 'Translated UI string',
isRequired: true,
isLocalized: true,
group: 'Content',
sortOrder: 20,
format: 'shortString',
},
},
});
Key decisions:
baseType: '_component'— not routable, no URL needed.Namespace+Keymirror next-intl‘snamespace.keymessage structure, so CMS shape maps directly to frontend shape.format: 'shortString'— dictionary values are short UI strings, not rich text.
Registering and Publishing the Content Types
New content types need to be added to the content type registry before they can be pushed to the CMS. Import DictionaryItemCT and DictionaryFolderCT and include them in the initContentTypeRegistry call alongside your existing content types:
import { DictionaryItemCT } from '@/src/content-types/component/DictionaryItem';
import { DictionaryFolderCT } from '@/src/content-types/folder/DictionaryFolder';
import { initContentTypeRegistry } from '@optimizely/cms-sdk';
initContentTypeRegistry([
DictionaryItemCT,
DictionaryFolderCT,
]);
Once both content types are registered, push them to Optimizely SaaS CMS using the CMS CLI push command:
npx @optimizely/cms-cli config push
This registers DictionaryFolder and DictionaryItem in the CMS, making them available to content editors from the content tree.
Setting Up the Dictionary Folder via Optimizely REST API
The Dictionary folder needs to be created under the For All Applications node before any DictionaryItem entries can be added beneath it. However, the CMS UI doesn’t currently provide a direct way to create custom folders at this level — so we’ll use the Optimizely REST API to create the folder. Once it exists, DictionaryItem entries can be added underneath it directly from the CMS UI.
The exact API endpoint, payload structure, and step-by-step folder creation are covered in Part 2.
Reading Dictionary Items and Transforming to next-intl Messages
Since folder content isn’t exposed in Optimizely Graph, we query DictionaryItem directly by type and locale, then reshape the flat results into the nested { namespace: { key: value } } structure next-intl expects.
CMS client — executes queries against Optimizely Graph:
import { config, getClient } from '@optimizely/cms-sdk';
config({
apiKey: process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!,
graphUrl: process.env.OPTIMIZELY_GRAPH_URL,
});
export const cmsClient = getClient();
Fetch and transform — full implementation:
import 'server-only';
import { cmsClient } from '@/src/lib/cms-client';
import type { Locale } from '@/src/i18n/routing';
// Shape of a single DictionaryItem returned by Optimizely Graph
type DictionaryItemRecord = {
Namespace?: string | null;
Key?: string | null;
Value?: string | null;
};
// Fetch all DictionaryItem content for a given locale.
// Folder content isn't exposed in Graph, so we filter directly on content type
// via _metadata.types, and scope results to the requested locale.
const DICTIONARY_QUERY = `
query DictionaryItems($locale: String!) {
_Content(where: { _metadata: { types: { eq: "DictionaryItem" }, locale: { eq: $locale } } }) {
items {
... on DictionaryItem {
Namespace
Key
Value
}
}
}
}
`;
// Groups flat DictionaryItem records into next-intl's expected
// { namespace: { key: value } } message structure.
// Exported separately (not just inlined) so it can be unit tested
// without hitting the CMS — pass it a plain array and assert on the output.
export function buildMessages(items: DictionaryItemRecord[]): Record<string, Record<string, string>> {
const messages: Record<string, Record<string, string>> = {};
for (const item of items) {
// Trim to guard against accidental whitespace entered by editors in the CMS UI
const namespace = item.Namespace?.trim();
const key = item.Key?.trim();
const value = item.Value;
// Skip incomplete entries — missing namespace, key, or an empty/null value.
// Prevents a partially-filled CMS item from crashing next-intl at render time,
// since next-intl expects every referenced key to resolve to a string.
if (!namespace || !key || value == null || value === '') {
continue;
}
// Lazily create the namespace bucket the first time we see it
if (!messages[namespace]) {
messages[namespace] = {};
}
messages[namespace][key] = value;
}
return messages;
}
// Entry point used by i18n/request.ts to load CMS-sourced messages for a locale.
// Marked via 'server-only' import at the top of the file
// this must never run on the client, since it uses the Graph API key.
export async function getDictionaryFromCms(locale: Locale): Promise<Record<string, Record<string, string>>> {
const data = await cmsClient.request(DICTIONARY_QUERY, { locale });
// Defensive fallback — if Graph returns no items (e.g. empty dictionary,
// or a locale with no translated entries yet), default to an empty array
// rather than letting the destructure throw.
const items: DictionaryItemRecord[] = data?._Content?.items ?? [];
return buildMessages(items);
}
A couple of implementation details worth calling out:
'server-only'at the top of the file is a Next.js guard that throws a build-time error if this module is ever imported into client code — important here sincecmsClientcarries the Graph API key.- Separation of query, transform, and orchestration — DICTIONARY_QUERY fetches raw data, buildMessages reshapes it, and getDictionaryFromCms ties them together.
getDictionaryFromCms is the function called downstream by i18n/request.ts to load messages per locale.
Note: The GraphQL call above fetches dictionary items authored in the CMS — how and where to create those items is covered in Part 2.
Wiring next-intl into Next.js
With CMS-sourced messages available via getDictionaryFromCms, the next step is connecting next-intl into the Next.js App Router pipeline.
next.config.js — apply the next-intl plugin:
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
/* config options here */
};
export default withNextIntl(nextConfig);
createNextIntlPlugin points to the request config file (i18n/request.ts) and wraps nextConfig to enable next-intl’s build-time integration across the app.
i18n/routing.ts — define supported locales:
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'nl'],
defaultLocale: 'en',
});
export type Locale = (typeof routing.locales)[number];
routing centralizes the supported locale list and default locale, and is shared across routing, middleware, and the request config.
proxy.ts — locale-aware middleware:
typescript
import createMiddleware from 'next-intl/middleware';import { routing } from '@/src/i18n/routing';// In Next.js 16 the middleware file is named `proxy.ts`, but next-intl's helper// is still imported from `next-intl/middleware`.export default createMiddleware(routing);export const config = { // Match all pathnames except for API routes, Next internals, and files with a dot. matcher: '/((?!api|_next|_vercel|.*\\..*).*)',};
createMiddleware(routing) uses the shared routing config to detect and resolve the locale for each incoming request — handling locale prefix redirects and locale negotiation before the request reaches a page. The matcher scopes this to actual page routes, excluding API routes, Next.js internals, and static files.
i18n/request.ts — load CMS messages per request:
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { getDictionaryFromCms } from '@/src/lib/dictionary/getDictionaryFromCms';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
const messages = await getDictionaryFromCms(locale);
return {
locale,
messages,
getMessageFallback() {
return '';
},
};
});
requestLocale resolves the locale for the current request, set upstream by the middleware. hasLocale validates it against routing.locales and falls back to defaultLocale if invalid. messages is populated directly from the CMS via getDictionaryFromCms. getMessageFallback returns an empty string for any unresolved key, keeping missing translations silent in the UI instead of surfacing placeholder text.
Layout — set the locale for static rendering:
import { setRequestLocale } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ lang: string }>;
}>) {
const { lang } = await params;
setRequestLocale(lang);
return (
<html lang={lang}>
<body className="min-h-full flex flex-col">
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
setRequestLocale(lang) tells next-intl which locale is being rendered for the current request, enabling static rendering to work correctly per locale segment in the App Router. NextIntlClientProvider wraps the app so messages and locale resolved on the server are available to any client components that call useTranslations further down the tree.
Putting It Together in a Component
With messages flowing from the CMS through next-intl, any server or client component can now resolve dictionary values using the namespace.key pattern defined in the content types.
Server component — using getTranslations:
import { getTranslations } from 'next-intl/server';
export default async function Hero() {
const t = await getTranslations('home');
return (
<section>
<h1>{t('welcome')}</h1>
<button>{t('readMore')}</button>
</section>
);
}
Client component — using useTranslations:
'use client';
import { useTranslations } from 'next-intl';
export default function ReadMoreButton() {
const t = useTranslations('common');
return <button>{t('readMore')}</button>;
}
In both cases, the namespace passed to getTranslations/useTranslations ('home', 'common') corresponds directly to the Namespace field on a DictionaryItem, and t('readMore') resolves to the Value of the item whose Key is readMore within that namespace.
Here’s how the complete flow looks:

Final Thoughts
This post covered the full path for retrieving dictionary items in Optimizely SaaS CMS: modeling DictionaryFolder and DictionaryItem content types, registering and pushing them via the CMS CLI, creating the Dictionary folder through the REST API, querying items via Optimizely Graph, transforming them into next-intl’s message shape, and wiring everything through middleware, layout, and components.
In Part 2, we’ll cover the editorial side: creating and managing DictionaryItem entries at scale, key/value authoring patterns, and best practices for organizing namespaces as the dictionary grows.
Happy Optimizing!!!