useExtracted: The Tailwind of i18n?
Nov 7, 2025 · by Jan AmannFor quite some time, I used to be a Tailwind skeptic. But one day, I decided to give it five minutes — and I was hooked. It’s core idea is just so irresistibly pragmatic. Once you’ve tried it, you just can’t go back.
It made me wonder: “What would the Tailwind for i18n look like?”
Now first of all, next-intl is doing very well and has recently crossed 1M weekly downloads (thank you everyone!). So it seems like there are plenty of people who are quite happy with how it works today. And it’s core APIs like useTranslations are surely here to stay.
But will we continue to write code the same way tomorrow, as we do today?
It seems evident that we will not. AI agents are increasingly making their way into our workflows, and innovations like Cursor and Claude Code are becoming more powerful by the day.
So what makes a library suitable for AI-first development? Probably, that it was already a great option for humans in the first place. Tailwind wasn’t “built for AI”, it was a fantastic idea for how to make styling easy for humans. But now, it’s become the default for agentic tools when it comes to styling.
So what makes Tailwind Tailwind?
Design principles
If we consider the design of Tailwind, we can see that an i18n solution that follows the same principles might look something like this:
- Colocation: Similar to how Tailwind avoids the need to manage separate stylesheets, there should not be a need for manually managing JSON message catalogs when adding, updating or removing messages. Message catalogs can however act as a compile target.
- Local reasoning: Generative AI is very good at Tailwind since it only requires very small context windows. Having to read entire message catalogs leads to context pollution and should therefore be avoided (at least not without tool calls).
- No naming of things: Not having to come up with names is a major productivity boost, therefore manual keys should be avoided as much as possible.
- Purging: When code is quickly changed, there shouldn’t be any dead code left behind. Similar to how Tailwind can purge unused styles, we should purge unused messages automatically.
- Minification: Tailwind class names have a tiny bundle footprint. In the same way, messages should also use minified keys that ensures bundles are as small as possible.
- Prototype-friendly, production-ready: Tailwind looks exactly the same, regardless of whether it’s used for a quick prototype or a production app. In the same way, there should be a single API that avoids upfront structural decisions related to the project’s size and complexity.
- Refactoring-friendly: Moving code across components is seamless with Tailwind, this should be the case for your messages as well.
While next-intl has answers to some of these questions, ultimately the truth is that there’s currently potential left on the table. Therefore, after publishing an RFC about two months ago—today, I’m incredibly excited to share what I believe could be the answer to this:
import {useExtracted} from 'next-intl';
function InlineMessages() {
const t = useExtracted();
return <h1>{t('Look ma, no keys!')}</h1>;
}useExtracted in action
I already made you read far too much, so here’s a demo to show you how it works.
Let’s start with an app that has next-intl installed, and we’ll make a previously static label eligible for translation to other languages:
No naming of keys, no CLI to invoke, just next dev as you’re used to.
And you get an always-in-sync JSON catalog for free.
Everything you like about useTranslations, but without the keys
If you’ve internationalized an app before, you know that we need to translate more than just plain strings.
So let’s use some ICU features:
Yep, TypeScript will automatically validate that you’re using the number formatter in your messages when needed—all without additional setup. By doing this, we can ensure that Intl.NumberFormat will be used to turn a raw number into a readable string that uses locale-sensitive formatting.
Also t.rich is of course supported:
t.rich('Please refer to the <link>guidelines</link>.', {
link: (chunks) => <Link href="/guidelines">{chunks}</Link>
});And yes, there’s also an awaitable version for Server Components and friends:
import {getExtracted} from 'next-intl/server';
export default async function ProfilePage() {
const user = await fetchUser();
const t = await getExtracted();
return (
<PageLayout title={t('Hello {name}!', {name: user.name})}>
<UserDetails user={user} />
</PageLayout>
);
}Ok, now let’s take a step back. Components that use inline messages appear to be easy to generate for AIs, all without having to load lengthy message catalogs into its precious context window.
But what about using AI to translate your messages?
Context is key
While providing context was always important for translators, it seems like in the AI era, doing this in an easy-to-digest way that’s based on text is even more important.
If you’ve previously used hand-crafted keys like auth.login.title, you already did your part to provide some context that clarifies the intent of the message.
But what if our translations look like this:
{
"0MXX5B": "Welcome back!"
}Not so easy.
But again, making something easy for AIs was probably always about making it easy for humans in the first place. And we already found a solution to this—wait for it—30 years ago!
GNU gettext introduced .po files for message catalogs, which look like this:
#. Greeting shown to user when logging back in
#: src/app/(auth)/login/page.tsx
msgid "0MXX5B"
msgstr "Welcome back!"Everything is there: An optional description, a filename reference, an ID and the label itself.
So this is what you’ll now be able to use with next-intl as well:
Note how enabling the .po formatter also activates a Turbopack loader that will parse your locale catalogs as plain JavaScript objects for you, making them easy to consume in your app. It’s really just a compile target.
That being said, if JSON is your jam, that’s totally fine too. And soon, there will also be support for custom formatters that will allow you to incorporate file references and descriptions in whatever format you prefer.
Now we’re ready to translate
With this, we’re in a much better position to get accurate and user-friendly translations.
So first of all, let’s add a new locale:
Nope, the video wasn’t cut. Neither was there a paste from the clipboard. When you add a new locale, next-intl will automatically populate the catalog with empty entries for all messages that you support.
So from here, you can start translating your messages however you want, either with an editor, or in the easiest case by using an AI-based translation service like Crowdin:
More on this in the localization management docs.
What sorcery is this?
Behind the scenes, useExtracted integrates with Next.js at two touch points:
1. Turbopack loader for extration
The core piece is a loader that will be called for source files containing useExtracted calls.
Note that only Next.js 16+ is supported, since it introduced an optimization for Turbopack that allows to peak inside files to check if they even use the useExtracted hook before actually processing the file.
If useExtracted is found, then SWC—the Rust-based compiler that powers Next.js—will parse the file, followed by a JavaScript transformer that will compile the file to a useTranslations call:
import {useTranslations} from 'next-intl';
function InlineMessages() {
const t = useTranslations();
// Pick up the minified key
return <h1>{t('dPSc42')}</h1>;
}Additionally, in case there are changes to the messages previously known for this file, the loader will also emit an updated messages catalog for your source locale, and also your target locales will be kept in sync. By doing this, Turbopack HMR will reflect the changes instantly in your running app.
If no messages were changed as part of a save, the extraction part will be skipped altogether.
2. Turbopack loader for catalogs
To support catalog formats like .po (and custom ones in the future), a second Turbopack loader will be enabled that efficiently reads your catalogs and returns them as plain JavaScript objects:
import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async () => {
const locale = 'en';
// E.g. `{"NhX4DJ": "Hello"}`
const messages = (await import(`../../messages/${locale}.po`)).default;
// ...
});Oh and yes, Webpack is also supported.
Ready to try it?
I could probably go on, e.g. about how useExtracted also works out-of-the-box in test environments without any compilation at all, but I think I’ve already made my point.
If you’re excited about this as well, I’d really love to hear your feedback.
Give the demo app a try and share your feedback with me. If you’re an early adopter, you can already try this feature in your app with next-intl@4.5, but please note that it’s currently considered experimental, so changes should be expected.
Also, it’s worth mentioning again that the useTranslations API that you might be using today is not going anywhere. If you’re already happy with it, then by all means keep using it. Personally, I think useExtracted has a lot of potential, but only time will tell if this is true.
As the feature stabilizes, you’ll of course be the first to hear about best practices and in-depth tutorials if you’re a member of 🌐 learn.next-intl.dev.
— Jan
PS: A special thank you goes to projects & companies like gettext, Lingui, FormatJS, Wordpress, and Zendesk for their pioneering work in the space of message extraction. I’d also like to thank Jan Nicklas for his work on next-yak, pushing the boundaries of Turbopack and sharing his insights.
Further reading:
Let’s keep in touch: