vs next-intl
next-intl is a runtime. Paragraphs is the backend.
They compose. They don't compete. Keep your next-intl config exactly as it is — and add a translation source.
Where it earns its place.
- Locale routing and middleware
- Server-side rendering per locale
- ICU MessageFormat at runtime
- Type-safe message keys
- Date / number / currency formatters
- next-intl/navigation locale-aware Link
It's a great library. We use it on this very site. We're not trying to replace it.
The questions that pile up.
- Where do the messages come from? Hand-translated? Machine-translated? Spreadsheet?
- Who reviews translations before they go live?
- How do CMS-sourced strings (Sanity, headless WP, Payload) get into
messages/? - How do you keep brand glossary enforced across 12 locales?
- How do you stage and merge a campaign's translations atomically?
- What about translated JSON-LD, sitemaps, llms.txt?
One postbuild script.
Paragraphs syncs translations from the graph into ./messages/{locale}.json. next-intl reads them at runtime, exactly as before.
import { syncToNextIntl } from "@paragraphs/next";
await syncToNextIntl({
apiKey: process.env.PARAGRAPHS_API_KEY!,
projectId: process.env.PARAGRAPHS_PROJECT_ID!,
outputDir: "./messages",
locales: ["en-GB", "es-ES", "fr-FR", "de-DE"],
});
// Run as a postbuild step:
// "build": "next build && tsx scripts/sync-translations.ts"
// Or on a Paragraphs webhook trigger for instant updates. When to add Paragraphs to next-intl.
| Your situation | next-intl alone | + Paragraphs |
|---|---|---|
| Tiny site, hand-translated UI strings only | Yes — perfect fit | Overkill |
| UI strings + a few static marketing pages | Yes, if you have a translator | Adds review workflow + memory |
| UI strings + CMS-sourced content | No — messages/ won't cover it | Yes — sync CMS units into the graph |
| 10+ locales with frequent updates | Slow to maintain | Yes — fingerprinted updates |
| You need AISEO / translated JSON-LD | No surface for it | Yes — first-class |
| E-commerce with hundreds of products | Out of scope | Yes — Translation Memory shines |
Where next-intl on its own breaks down.
If your content lives in Sanity, headless WordPress, Payload, or a custom CMS, you can't just stuff it into messages/. Paragraphs' adapters give you locale-aware server components that handle the fetching and substitution for you.
import { fetchProduct } from "@paragraphs/next";
import { getTranslations } from "next-intl/server";
export default async function ProductPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: "Product" });
// Paragraphs fetches the product from your CMS, then
// substitutes any translatable units for the requested locale.
const product = await fetchProduct({ slug, locale });
return (
<article>
<h1>{product.title}</h1>
<p>{product.description}</p>
<button>{t("addToCart")}</button>
</article>
);
} Already on next-intl?
Drop in @paragraphs/next as a sync source. Your runtime stays untouched.