Skip to main content

Feature deep-dive · SaaSForge Agency

Directus multilingual template for Next.js agency sites (EN / FR / ES)

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.

Fetching a translated service in a Server Component
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.

Frequently asked

Why split UI strings and content instead of putting everything in Directus?
UI strings are tied to component behaviour: a button label changes when the button's interaction changes. Putting them in a CMS adds latency to the dev loop without giving editors anything useful (most marketers do not want to edit 'Submit' to 'Send'). Content strings are tied to marketing decisions and benefit from a draft-and-preview workflow. The split matches the actual ownership rather than forcing both sides through one tool.
How are translations kept in sync if a developer changes a UI string?
They are not auto-synced; the message catalogues are versioned in git like any other code. A pull request that adds a new translation key fails type-checking until every locale catalogue has the key, which is the same enforcement most i18n setups use. CMS content has no parallel relationship to code, so there is no sync question on that side.
Does this work without Directus, just next-intl?
Yes, but you lose the editor workflow for content. If your team is small enough that developers also write the marketing copy, next-intl alone is fine and you would pick SaaSForge Starter (single locale) or extend it manually. The Directus pairing earns its keep when the editor is a non-developer client or a marketing team member who should not be opening pull requests.
Can I run two locales instead of three?
Yes. Set the locales list to whatever subset you ship (e.g., EN and FR only) and remove the Spanish catalogue and translations. The routing middleware and Directus collections both accept any number of locales; the default of three is a starting point, not a hard requirement.
Ships in SaaSForge Agency

See SaaSForge Agency. Skip the deliberation.

Full source code. Lifetime updates. Polar Merchant-of-Record checkout. Private GitHub repo on purchase.