Directus multilingual template for Next.js agency sites (EN / FR / ES)
Multilingual marketing sites usually pick one tool and force the other side to bend. CMS-only translations leave developers editing copy in admin; code-only translations leave marketers filing tickets to update a case study in Spanish. The Directus plus next-intl combination splits the surface by ownership: content goes to the people who write content, UI strings go to the people who write code, and the build pipeline stops being a single bottleneck.
Two translation surfaces, one site
UI strings (button labels, form errors, nav items) live in next-intl message catalogues under `messages/<locale>.json`. They change rarely, they need to be type-safe across the codebase, and developers iterate on them alongside the components they sit in. Content strings (case-study body, service descriptions, blog posts) live in Directus collections with a `translations` related collection; marketers edit them in admin without touching git.
The boundary is enforced by where the data is read from, not by a sync job. A component pulls UI strings via `useTranslations()` and content via the Directus SDK. That split keeps the workflows clean: a developer never needs to log into Directus to fix a typo in a button label, and a marketer never needs a pull request to update a French case study.
How Directus translations actually work
Each Directus collection that needs translating gets a paired `<collection>_translations` collection with a `languages_code` field and the translatable fields. The admin UI surfaces a locale switcher inside each item, so editors edit French copy alongside the English source rather than navigating to a separate French version of the site.
Fetching from Next.js is a single query with the `translations` field expanded and filtered by the active locale. The typed Directus SDK keeps the response shape predictable; the Server Component receives an object with the locale-resolved fields already in place.
import { directus } from "@/lib/directus";
import { getLocale } from "next-intl/server";
export const revalidate = 600;
export default async function ServicePage({ params }: { params: { slug: string } }) {
const locale = await getLocale();
const [service] = await directus.request(
readItems("services", {
filter: { slug: { _eq: params.slug } },
fields: [
"slug",
"translations.title",
"translations.summary",
"translations.body",
],
deep: { translations: { _filter: { languages_code: { _eq: locale } } } },
limit: 1,
})
);
return <Service data={service.translations[0]} />;
}Per-locale SEO that does not leak across languages
Each locale variant of a page (e.g., `/en/services/seo`, `/fr/services/seo`, `/es/services/seo`) has its own metadata: title, description, OG tags, canonical URL pointing to itself. hreflang alternates declare the relationship to every other variant so Google understands these are not duplicate content.
The sitemap generator enumerates every locale variant of every CMS-driven page, joining the Directus collection list with the next-intl locale list. Structured data (Organization, FAQPage, BreadcrumbList) is generated per-locale where the field values change, so a Spanish visitor sees Spanish JSON-LD on a Spanish URL, not English JSON-LD on a Spanish nav.
Adding a fourth locale without rewriting
Adding a new language is three steps: add the locale code to next-intl config, add the locale row to Directus's `languages` collection, and start translating. Routing automatically expands to `/de` (or whatever code you added) on the next deploy; existing collections accept the new locale immediately. The trickier part is finding translators, not the technical change.
RTL languages (Arabic, Hebrew) are not in the default locale set but are supported in principle. You would flip the document `dir` attribute in the locale layout and audit the components for RTL-friendly layout. The template does not pretend RTL support is free; that audit is documented as a known scope of work for buyers who need it.