Next.js · next-intl
The messages your server renders, reviewed before they deploy.
next-intl reads one nested JSON file per language out of messages/, with ICU for plurals and variables. Mergua reads and writes exactly that file, so request.ts keeps importing what it imports today, and every language gains a branch, a review step and a pipeline that fills it in.
Next.js + next-intl
/for/next-intl
locale files
messages
file format
nested
The files you already have
Mergua reads what is in your repo. Nothing is renamed, nothing moves, and no package is added to your project — the sync script is fetched with curl when the pipeline runs.
messages/
├── en.json ← source locale
├── de.json
└── fr.json{
"cart": {
"title": "Your cart",
"items": "{count, plural, =0 {Your cart is empty} one {One item} other {{count} items}}",
"checkout": "Checkout"
}
}How next-intl reads them
This is next-intl's own setup, not Mergua's — if you already have it, skip the panel. It is here so the page is a working path from empty project to translated screen rather than a page about a product.
import { useTranslations } from 'next-intl';
export function CartSummary({ count }: { count: number }) {
const t = useTranslations('cart');
return (
<section>
<h1>{t('title')}</h1>
<p>{t('items', { count })}</p>
</section>
);
}next.config.ts
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {};
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async () => {
const locale = 'en';
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});src/app/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getLocale } from 'next-intl/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}src/i18n/request.ts
// next-intl wants one object per language, so a second file
// is a second import. Lay them out language folder first and
// name each file after the top-level key it fills:
//
// messages/en/cart.json
// messages/en/account.json
export default getRequestConfig(async () => {
const locale = 'en';
return {
locale,
messages: {
cart: (await import(`../../messages/${locale}/cart.json`)).default,
account: (await import(`../../messages/${locale}/account.json`)).default,
},
};
});npm i next-intlBack into the build
One flag, because messages/ sits at the project root rather than where the CLI looks by default. Keys you added in code go up, finished translations come back down as a commit on the branch the job is running on, and the next deploy imports them.
The job runs on the branch it is already on, so a feature branch syncs against the matching Mergua branch and main against main. That is the part no other translation tool does.
And you do not have to run it on a timer: a project can POST to a URL of yours when translations change or a branch is merged, so the job above starts on the change. The delivery is signed, it is filtered to the branch you name, and an import of four hundred keys arrives as one call.
# --path, because the CLI defaults to Angular's src/assets/i18n.
# --format stays nested, which is what next-intl reads.
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path messages.github/workflows/i18n.yml
name: Mergua sync
on: push
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path messages
env:
MERGUA_API_KEY: ${{ secrets.MERGUA_API_KEY }}
MERGUA_PROJECT_ID: ${{ vars.MERGUA_PROJECT_ID }}# If you would rather own the commit yourself, pull and
# commit in your own step:
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- pull --path messagesPlaceholders
next-intl writes a variable as {count}. Mergua treats it as part of the string: if a translation comes back without it, the validator says so on the way out, and the AI translator is told to carry it through.
"items": "{count, plural, =0 {Your cart is empty} one {One item} other {{count} items}}"t('items', { count: 3 })Between the push and the pull
The job above is both ends of one loop, and what happens in between is people. A translation gets written, somebody else approves it, and sometimes a whole language leaves for an agency and comes back — none of which touches a file in your repo until the next pull.
Reviewed before the server renders it
next-intl hands the messages file to every server component on the request, so a wrong string is in the HTML before any client code runs — and a crawler indexes it as it is. A reviewer approves or rejects one key at a time with the reason attached to the key, a review request mails everyone who could answer it, and a branch does not merge while anything on it is unreviewed.
How a key gets from draft to reviewedA language leaves as XLIFF, messages/ stays JSON
An agency working in a CAT tool gets a language as one XLIFF 2.0 document and sends it back into the same keys. next-intl never sees the document and the sync script does not carry it, deliberately: the document is the handoff, and the messages file request.ts imports is still the JSON Mergua wrote.
What travels in the document and what does notWorth knowing on Next.js
A dot is a path, never part of a key
next-intl reads t('cart.title') as a path through nested objects and refuses a message key with a dot in it. That is why the format stays nested: Mergua writes cart.title back as an object holding title, the shape next-intl expects. A file pulled with --format flat would hand it keys it rejects.
Plurals are ICU, and checked as ICU
{count, plural, …} is parsed for its structure, so a translation that loses {count} from the case that used it is flagged on the way out, while one that adds few and many for Polish is left alone. The AI translator is told to keep the placeholders as well.
How the placeholder check reads ICUSeveral files, one object per language
A Mergua project holds a folder per language, messages/en/cart.json, and the file name is what its keys are filed under. next-intl still wants one object, so request.ts imports each file under the key it is named after — the "split" tab above — and useTranslations('cart') reads the file of the same name.
Splitting a language across several filesA different stack?
Mergua writes plain locale files, so anything that loads one works. These stacks have a page of their own as well.
Run it on one branch.
Upload the messages/en.json you already have and see the whole loop on the free tier.
Create your account