Ask Cursor or Claude Code to add i18n to a Next.js app today and you will very likely get middleware.ts, setRequestLocale in every layout, and useTranslations called from an async page — a setup that was correct for Next.js 15 and is one generation stale on Next.js 16. globalize.now is AI-powered localization infrastructure, so this is the layer we watch: the routing wiring changed, the catalog it serves did not. Below is what moved, how to tell which era your generated code came from, and the three fixes that take an afternoon rather than a rewrite.
None of it is a breaking change. That is precisely why it is easy to miss.
What changed in Next.js 16 for i18n?
The file that negotiates your locale changed its name. Next.js documents the middleware file convention as deprecated and renamed to proxy, landing in v16.0.0, and its own explanation is about vocabulary — "middleware" kept getting read as Express middleware, so the feature was renamed to describe the network boundary it actually is.
Two behaviour notes ride along with the rename in v16. Proxy defaults to the Node.js runtime, and the per-file runtime config option is no longer available there — set it and Next.js throws. Proxy is also unsupported on static export, which matters if your locale negotiation is the only reason you are not exporting.
For locale routing specifically, the next-intl setup guide now shows the file as src/proxy.ts and notes plainly that it was called middleware.ts up until Next.js 16. The import inside it is unchanged:
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};
Note the asymmetry, because it trips people up: the file is now proxy.ts, while the import path is still next-intl/middleware. Renaming the import is a common over-correction.
Why does AI-generated i18n code come out a version behind?
Because a coding agent predicts the most-represented pattern, and the most-represented pattern is the one that had years to accumulate. Every blog post, Stack Overflow answer and GitHub example about App Router locale routing written before late 2026 says middleware.ts. A model weighing that corpus against a few weeks of v16 documentation will reach for the old shape, confidently, with no warning that a rename happened.
This is the same mechanism behind a problem we have written about before, where Cursor keeps adding hardcoded strings after i18n setup — the agent reproduces the statistically normal codebase, not yours. It is also why instruction files only partly fix it for GitHub Copilot: rules that the chat surface reads are not necessarily read by inline completion.
The practical consequence is narrow but real. Your generated setup works, so nothing fails loudly. Then you hit a bug, search for it, and every current answer describes files you do not have.
How do I tell which era my generated setup is from?
Four greps. Run them from the project root and you will know in about a minute.
# 1. Pre-16 locale negotiation file
ls middleware.ts src/middleware.ts 2>/dev/null
# 2. Legacy static-rendering API
grep -rn "setRequestLocale" app src 2>/dev/null
# 3. Hooks called inside async components
grep -rn -B3 "useTranslations" app | grep -n "async function"
# 4. Which next-intl era the config reads from
grep -rn "root-params\|await params" src/i18n/request.ts 2>/dev/null
Hits on 1 and 2 mean pre-16 scaffolding. A hit on 3 is a genuine runtime error waiting for the first request that renders that component. No hit on 4 means your request config is reading the locale the older way.
Do I have to rename middleware.ts to proxy.ts?
Not urgently, and you should not do it by hand. Next.js ships a codemod that renames both the file and the exported function:
npx @next/codemod@canary middleware-to-proxy .
The rename is a deprecation, not a removal, so an existing middleware.ts keeps working. The reason to run it anyway is maintenance cost: once your filenames match current documentation, every future search result applies to your repo. Leave it stale and you pay a small tax on every debugging session, forever.
Is setRequestLocale deprecated in next-intl?
It is marked legacy, which is a softer claim than deprecated and worth reading precisely. The next-intl documentation describes setRequestLocale as an API that existed until next/root-params was introduced, says it remains supported for backwards compatibility, and recommends next/root-params instead.
The newer shape reads the matched locale inside your request config rather than threading it manually through every layout and page:
// src/i18n/request.ts
import * as rootParams from 'next/root-params';
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
export default getRequestConfig(async ({locale}) => {
if (!locale) {
const paramValue = await rootParams.locale();
if (hasLocale(routing.locales, paramValue)) {
locale = paramValue;
} else {
notFound();
}
}
return {locale};
});
next/root-params is available by default in Next.js 16.3 and later; on earlier versions it has to be enabled through experimental.rootParams. Follow the setup this way and static rendering comes for free, so long as you still export generateStaticParams for the [locale] segment.
The old approach required calling setRequestLocale in every page and layout you wanted statically rendered, before any other next-intl call, because Next.js renders layouts and pages independently. That is a rule an AI agent forgets on the fifth file it writes. Deleting the requirement deletes the class of bug.
Why does useTranslations crash in my async Server Component?
Because hooks cannot be called from async components, and useTranslations is a hook. This is a React Server Components constraint, not a next-intl quirk, and next-intl's answer is a parallel set of awaitable functions:
// Async component — await the server API
import {getTranslations} from 'next-intl/server';
export default async function ProfilePage() {
const user = await fetchUser();
const t = await getTranslations('ProfilePage');
return <h1>{t('title', {username: user.name})}</h1>;
}
// Non-async component — the hook is correct here
import {useTranslations} from 'next-intl';
export default function UserDetails({user}) {
const t = useTranslations('UserProfile');
return <h2>{t('title')}</h2>;
}
getFormatter, getNow, getTimeZone, getMessages and getLocale follow the same pattern. The second example is worth dwelling on: a non-async component with no interactive features is a shared component, and next-intl resolves the right implementation depending on whether it renders on the server or the client. So useTranslations in a Server Component is not a mistake — calling it from an async one is.
Why do I get the NextIntlClientProvider context error?
next-intl's troubleshooting notes give two causes, and they want opposite fixes. Either the component really is running on the client with no provider above it, in which case wrap it and pass the messages it needs, or it drifted into a client module graph when you expected server rendering, in which case pass it through children from a Server Component instead of importing it inside one.
The second case is the one AI-generated apps hit, because agents apply 'use client' generously to make interactivity work, and the directive is contagious down the import graph. The preferred pattern is to translate on the server and hand finished strings across the boundary:
import {useTranslations} from 'next-intl';
import Expandable from './Expandable'; // 'use client'
export default function FAQEntry() {
const t = useTranslations('FAQEntry');
return <Expandable title={t('title')}>{t('description')}</Expandable>;
}
If some component truly needs messages on the client, you can scope a provider around just that subtree rather than shipping every message to the browser — messages={null} on the root provider passes none at all.
What does none of this change?
Your locale files. Every fix above is routing and rendering wiring; the JSON your app loads is untouched by all of it. Which is the point worth taking away, because the wiring is a one-afternoon job that changes once a year, while the catalog is the thing that rots every week as new UI ships.
That is the split we build for. next-intl and its neighbours serve translations at runtime — we do not compete with them, and if you are still choosing between them, next-intl vs react-i18next vs Lingui lays out the trade-offs. globalize.now sits one layer up, producing the keys and locale files those runtimes read, which is why the Next.js integration is indifferent to whether your negotiation lives in middleware.ts or proxy.ts.
Conversion is one-time and happens in the app: you connect the repository, globalize.now converts the codebase once and opens a pull request with the catalog. After that, push jobs translate new catalog units as they appear — which is the failure mode described in why translation files keep drifting out of sync, and the reason that gap is worth closing before your third locale, not after.
If your project already has a hand-written next-intl setup, we have written about exactly what we do and do not touch. If it does not, the Cursor walkthrough is the shortest path in — with the caveat that its generated file is named for the older convention, so run the codemod after.
Where to start
Run the four greps. If you get hits on the first two, run the codemod, move your request config onto next/root-params, and you are current. Then look at the locale files, because that is the part that will still be drifting next month. If you are shipping an AI-built app and want the catalog handled rather than maintained, the vibe coders page is the overview and pricing is on its own page.
globalize.now turns hardcoded app copy into translation-ready locale files and keeps them updated as you ship.
Try globalize.now free