js-lingui
js-lingui copied to clipboard
NextJS App Router and RSC Support path
Since NextJS app router is marked as stable we should provide a path fo users how to use Lingui with that.
Few main caveats here:
- Server Components doesn't support Context, so you could not use
<Trans>
directly in RSC. - There is no native support for i18n as in Pages router. So a migration path and some suggestions should be provided.
For the point number one there are few solutions:
- Users could use just
t
ori18n._
directly in server components. While that would work, it is hard to translate messages which may have other JSX elements inside. - We can create a separate
<TransWithoutContext>
component (and macro as well), which will not use context and context would be passed as props directly to component.
Checklist:
- [ ] Support Server components
- [ ] Create example for nextjs with app router
- [ ] Add guide to the documentation (https://github.com/lingui/js-lingui/issues/1876)
I think that the main challenge would be to be able to detect if the component is a server component or a client one.
Then the second problem would be to remove the useLingui() context hook in the Trans
component.
For that, maybe you could just read the i18n instance on the server ๐ค
Here's what I tried on local :
In my root layout, I do :
const RootLayout = async ({ children }: { children: ReactNode }): Promise<JSX.Element> => {
const language = getLanguage();
const messages = await getDictionary(language);
i18n.loadAndActivate({ locale: language, messages });
...
Then in a page, I've logged the i18n object and my messages were stored in i18n.messages.
So I just wrote in a RSC page :
const Page = (): JSX.Element => {
const cookieStore = cookies();
return (
<div>
<TransServer
id="greeting"
message="Hello {name}"
values={{ name: 'Arthur' }}
/>
// here I put the id that lingui generated
<TransServer id="XkgyrZ" message="Vous gagnez {reward}" values={{ reward: 'a cookie' }} />
It seems to work !
For my TransServer component I just copied the Trans component in lingui and changed it like the following gist (only take a look at the latest diff) https://gist.github.com/raphaelbadia/82f1c202e57b557bf88ea04cbbc0be29/revisions
This approach is very naive, but it seems to work, what do you think about it?
Extractor will not extract messages from your custom Trans components. But approach in the right direction. I wrote it as
We can create a separate element (and macro as well), which will not use context and context would be passed as props directly to component.
Something like
import { TransNoContext as Trans } from '@lingui/macro';
import {setupI18n, I18n} from "@lingui/core"
async function getI18n(local: string): I18n {
// assume you already implemented a way to load your message catalog
const messages = await loadMessages();
return setupI18n({locale, messages})
}
// RSC
export async function Page() => {
const params = useParams();
const i18n = await getI18n(params['locale']);
return (<div>
<Trans i18n={i18n}>Hello world!</Trans>
</div>)
}
So changes in macro and swc plugin would be needed as well as new component is introduced in lingui/react
I don't know much about babel / swc, but couldn't these plugins read the file, see if it contains the string "use client"
, and automatically choose between TransNoContext
and Trans
?
Would be better for the user to write import { Trans } from '@lingui/macro';
in all cases ๐
Also, your RSC example would be great but are you sure it's necessary to do const i18n = await getI18n();
and pass i18n to the Trans component in every server component?
I don't know much about babel / swc, but couldn't these plugins read the file, see if it contains the string "use client", and automatically choose between TransNoContext and Trans?
Would be better for the user to write import { Trans } from '@lingui/macro'; in all cases
Typescript users would not be happy. Explicit is always better than implicit. TransNoContext
would have i18n
prop as required, so developer could understand that they have to pass i18n
instance in that. Where regular Trans
would not have this requirement.
Also, your RSC example would be great but are you sure it's necessary to do const i18n = await getI18n(); and pass i18n to the Trans component in every server component?
Yes, it's not avoidable. The design of RSC & nextjs is strange in my opinion.
You could not use a global instance of i18n because your server is asynchronous, and could process few requests at the same time. That may cause that global instance would be switched to different language even before first request would be fully generated. Unfortunately, nextjs also does not expose any native request or even syntetic event / context for each ssr request where we can create and store i18n instance for later reuse. So the only one way i'm seeing is instantiating it explicitly where you need it.
I haven't dug too much in NextJs internals, i saw that they somehow implement useParams()
and cookies()
as a global functions. May be some compilation time transformations involved to make it work.
I've dug through @amannn's next-intl package and I found their approach interesting, here's how I (think ?) it works:
- User has to create and i18n.ts file in the root repository that exports a function that fetch translations
loadMessages()
- User calls
useTranslations()
in a RSC -
useTranslations()
calls getRuntimeConfig from a fake packagenext-intl/config
which is aliased by a webpack plugin to userland's i18n.ts
nextjs also does not expose any native request or even syntetic event / context for each ssr request where we can create and store i18n instance for later reuse.
react provides this capability with the cache()
function so I think it would be possible to do something like next-intl.
From your previous example :
// in lingui.config.ts
import { setupI18n } from "@lingui/core"
export const getI18nInstance = cache(() => setupI18n());
export const loadMessages = cache(() => import(`@lingui/loader!./locales/${locale}/messages.po`))
// linguimonorepo/packages/react/server/TransNoContext.tsx
import { getI18nInstance } from '@lingui/webpack-plugin-get-userland-config';
export function TransNoContext(TransProps) {
const i18n = getI18nInstance();
const messages = loadMessages();
return <Trans asUsual........../>
}
// then in RSC
export function Page() {
return <Trans>Hello</Trans>
}
I haven't dug too much in NextJs internals, i saw that they somehow implement useParams() and cookies() as a global functions. May be some compilation time transformations involved to make it work.
I dug into the cookies()
part of Nextjs internal, in their server they use an AsyncLocalStorage that wraps the whole render process. The cookies() and headers() fn just read the store (and it's read only). Would require to patch nextjs in node modules to add something to it, don't think it's possible
@raphaelbadia Your summary from above looks correct to me! I found that @next/mdx
also uses the approach with the webpack alias: https://github.com/vercel/next.js/blob/056ab7918fef27133072f62f41b991fa47e52543/packages/next-mdx/index.js#L24
In case you need an i18n routing middleware for Lingui to work with the App Router, the next-intl middleware is really decoupled from all the other i18n code, so you could likely use/suggest it too if you're interested!
The hardest part for next-intl currently is to retrieve the users locale in React components. We use a workaround where the locale is negotiated in the middleware and then read via a header from components. This works well, but unfortunately doesn't support SSG. The alternative is passing the locale through all component layers to your translation calls. I really hope that React Server Context becomes a thing to help with this.
I'm curious to follow along where Lingui ends up with the RSC support! ๐
Thanks for investigation.
export const getI18nInstance = cache(() => setupI18n());
We still need to create an i18n instance per request with specific locale. I don't see how this cache
function could help.
In regular express application i would do something like:
app.use((req) => {
// define and store instance in the middleware
req.i18n = setupI18n(req.params.locale);
})
app.get('/page', (req) => {
// use already crerated instance from Request, don't have to struggle with creating it every place.
console.log(req.i18n._('Hello'));
})
I believe React.cache is a cache per request.
It is !
What's the progress? Lingui
is really great. I like it very much and thank you for your active maintenance, but this question may determine whether my project can use it. I'm looking forward to using Lingui
in RSC.
I was able to make it all work together with a lot of dirty words and some webpack magic. The very early PoC is here https://github.com/thekip/nextjs-lingui-rsc-poc
How it works
There few key moments making this work:
- I was able to "simulate" context feature in RSC with
cache
function usage. Using that, we can use<Trans>
in any place in RSC tree without prop drilling.
// i18n.ts
import { cache } from 'react';
import type { I18n } from '@lingui/core';
export function setI18n(i18n: I18n) {
getLinguiCtx().current = i18n;
}
export function getI18n(): I18n | undefined {
return getLinguiCtx().current;
}
const getLinguiCtx = cache((): {current: I18n | undefined} => {
return {current: undefined};
})
Then we need to setup Lingui for RSC in page component:
export default async function Home({ params }) {
const catalog = await loadCatalog(params.locale);
const i18n = setupI18n({
locale: params.locale,
messages: { [params.locale]: catalog },
});
setI18n(
i18n,
);
And then in any RSC:
const i18n = getI18n()
- Withing this being in place, we have to create a separate version of
<Trans>
which is usinggetI18n()
instead ofuseLingui()
. I did it temporary right in the repo by copy-pasting from lingui source. - Having this is already enough, you can use RSC version in the server components and regular version in client. But that not really great DX, and as @raphaelbadia mentioned we can automatically detect RSC components and swap implementation thanks to webpack magic. This is done by:
- macro configured to insert
import {Trans} from 'virtual-lingui-trans'
- webpack has a custom resolve plugin which depending on the context will resolve the virtual name to RSC or Regular version.
- macro configured to insert
const TRANS_VIRTUAL_MODULE_NAME = 'virtual-lingui-trans';
class LinguiTransRscResolver {
apply(resolver) {
const target = resolver.ensureHook('resolve');
resolver
.getHook('resolve')
.tapAsync('LinguiTransRscResolver', (request, resolveContext, callback) => {
if (request.request === TRANS_VIRTUAL_MODULE_NAME) {
const req = {
...request,
// nextjs putting `issuerLayer: 'rsc' | 'ssr'` into the context of resolver.
// We can use it for our purpose:
request: request.context.issuerLayer === 'rsc'
// RSC Version without Context (temporary a local copy with amendments)
? path.resolve('./src/i18n/Trans.tsx')
// Regular version
: '@lingui/react',
};
return resolver.doResolve(target, req, null, resolveContext, callback);
}
callback();
});
}
}
Implementation consideration:
- Detecting language and routing is up to user implementation. I used segment based i18n by just creating
app/[locale]
folder. I think it's out of the Lingui scope. - We still need to configure separately Lingui for RSC and client components. It seems it's a restriction of RSC design which is not avoidable. So you have to use I18nProvider in every root with client components. (or do everything as RSC)
Hi @thekip, I love your solution but I am encountering a small problem that maybe you could help me with.
Problem
I'm using the Provider as you have in your repo where you pass setupI18n({messages, locale})
to the I18nProvider. That works fine for just Client components.
On the side I also have a validations file (I'm using zod for forms) where I have some customs messages that I want to translate with i18n, like:
import { i18n } from "@lingui/core";
export const schemaUpdateAccountSettings = z.object({
username: z.string().regex(usernameRegex, {
message: i18n.t(
"Username can only contain letters, numbers, underscores and dots.",
),
}),
locale: z.enum(languages),
});
I noticed that since you are creating a new i18n instance with setupI18n
to pass to the provider, when I import this validation file in my Client component (wrapped by the provider), it does not get correct translation since, of course, the instance is different.
My current solution
What I was trying was reusing the @lingui/core
instance also in the Provider.
I have this Client provider wrapped by a RSC that gets the locale and the messages and pass them as props.
I18nProvider.tsx
export const I18nProvider = async ({
children,
}: PropsWithChildren<unknown>) => {
const locale = await getLocale();
const messages = getMessages(locale);
return (
<ClientI18nProvider locale={locale} messages={messages}>
{children}
</ClientI18nProvider>
);
};
I18nProvider.client.tsx
// ... other imports
import { i18n } from "@lingui/core";
export const ClientI18nProvider = ({
locale,
messages,
children,
}: PropsWithChildren<{ locale: string; messages: Messages }>) => {
i18n.load(locale, messages);
i18n.activate(locale);
return (
<I18nProvider i18n={i18n}>
{children}
</I18nProvider>
);
},
But unfortunately I'm getting a React error in the Client provider when I'm changing the locale:
Warning: Cannot update a component (`I18nProvider`) while rendering a different component (`ClientI18nProvider`).
This is probably due to the Provider that internally changes state of locale when the props changes but I have no idea how to solve this.
Do you have other solution for my use case (i18n on files outside the provider but still Client side)?
Hi all, since [email protected] there is no need to copy Trans
implementation into the project. Check this commit: https://github.com/thekip/nextjs-lingui-rsc-poc/commit/088d04a0cb6734cdea508371a3eada81f0c9e46a
@413n firstly, this code is potentially broken:
import { i18n } from "@lingui/core";
export const schemaUpdateAccountSettings = z.object({
username: z.string().regex(usernameRegex, {
message: i18n.t(
"Username can only contain letters, numbers, underscores and dots.",
),
}),
locale: z.enum(languages),
});
Due to zod's schema definition happened on the module level and will not react on language changes or might suffer from race conditions (when catalogs are not loaded yet).
A better approach would be to use msg
macro, and store an ID of message in the zod schema. And retrieve real message from id in the place where you're actually executing your validations.
import { msg } from "@lingui/macro";
export const schemaUpdateAccountSettings = z.object({
username: z.string().regex(usernameRegex, {
message: (msg`Username can only contain letters, numbers, underscores and dots.`).id,
}),
locale: z.enum(languages),
});
Check the documentation for more info on this pattern https://lingui.dev/tutorials/react-patterns#lazy-translations
This will effectevely resolve your next problem, because you will not rely on the global i18n instance anymore.
@thekip I installed the latest version but TransNoContext seems to not be exported. I checked in the index and it seems like that. Could you check?
@thekip I just cloned your PoC repo (using pnpm) and it gives this error
import { msg } from "@lingui/macro"; export const schemaUpdateAccountSettings = z.object({ username: z.string().regex(usernameRegex, { message: (msg`Username can only contain letters, numbers, underscores and dots.`).id, }), locale: z.enum(languages), });
This will effectevely resolve your next problem, because you will not rely on the global i18n instance anymore.
I tried it but I had some problems with the TransNoContext import and then I also had some errors that said "Module 'fs' not found".
I will retry it once the TransNoContext problem is gone, but just to be sure: should I just add the swcPlugin
, install @lingui/macro
and wrap the error in i18n.t(here)
in order to apply the fix that you suggested me?
@thekip I installed the latest version but TransNoContext seems to not be exported. I checked in the index and it seems like that. Could you check?
@thekip I just cloned your PoC repo (using pnpm) and it gives this error
I have the same problem.
Hey guys, sorry, there was some mess with these exports. Created a new PR with a fix, hope it would be published soon.
@413n your case is not particular related to this issue, i proposing opening a new discussion with your problems and continue discussion there. Or ask me in discord.
Hi guys, the fix is already available in v4.5.0
I was able to make it all work together with a lot of dirty words and some webpack magic. The very early PoC is here https://github.com/thekip/nextjs-lingui-rsc-poc
How it works
There few key moments making this work:
- I was able to "simulate" context feature in RSC with
cache
function usage. Using that, we can use<Trans>
in any place in RSC tree without prop drilling.// i18n.ts import { cache } from 'react'; import type { I18n } from '@lingui/core'; export function setI18n(i18n: I18n) { getLinguiCtx().current = i18n; } export function getI18n(): I18n | undefined { return getLinguiCtx().current; } const getLinguiCtx = cache((): {current: I18n | undefined} => { return {current: undefined}; })
Then we need to setup Lingui for RSC in page component:
export default async function Home({ params }) { const catalog = await loadCatalog(params.locale); const i18n = setupI18n({ locale: params.locale, messages: { [params.locale]: catalog }, }); setI18n( i18n, );
And then in any RSC:
const i18n = getI18n()
- Withing this being in place, we have to create a separate version of
<Trans>
which is usinggetI18n()
instead ofuseLingui()
. I did it temporary right in the repo by copy-pasting from lingui source.- Having this is already enough, you can use RSC version in the server components and regular version in client. But that not really great DX, and as @raphaelbadia mentioned we can automatically detect RSC components and swap implementation thanks to webpack magic. This is done by:
- macro configured to insert
import {Trans} from 'virtual-lingui-trans'
- webpack has a custom resolve plugin which depending on the context will resolve the virtual name to RSC or Regular version.
const TRANS_VIRTUAL_MODULE_NAME = 'virtual-lingui-trans'; class LinguiTransRscResolver { apply(resolver) { const target = resolver.ensureHook('resolve'); resolver .getHook('resolve') .tapAsync('LinguiTransRscResolver', (request, resolveContext, callback) => { if (request.request === TRANS_VIRTUAL_MODULE_NAME) { const req = { ...request, // nextjs putting `issuerLayer: 'rsc' | 'ssr'` into the context of resolver. // We can use it for our purpose: request: request.context.issuerLayer === 'rsc' // RSC Version without Context (temporary a local copy with amendments) ? path.resolve('./src/i18n/Trans.tsx') // Regular version : '@lingui/react', }; return resolver.doResolve(target, req, null, resolveContext, callback); } callback(); }); } }
Implementation consideration:
- Detecting language and routing is up to user implementation. I used segment based i18n by just creating
app/[locale]
folder. I think it's out of the Lingui scope.- We still need to configure separately Lingui for RSC and client components. It seems it's a restriction of RSC design which is not avoidable. So you have to use I18nProvider in every root with client components. (or do everything as RSC)
@thekip How to make the t
function work on both client and server? I tried this example does not work.
Don't use t
macro with global i18n, use t(i18n)`Hello!`
or i18n._(msg`Hello`)
you can get i18n instance from
const i18n = getI18n()
@thekip I try to demonstrate the t
macro in this PR, Could you help to take a look if there are any issues with doing so? This boilerplate is simpler and does not require the use of React.cache
.
Seems like it mostly works but I noticed that if I use setI18n
only in the layout.tsx
it sometimes triggers server side error: Error: Lingui for RSC is not initialized. Use setI18n() first in root of your RSC tree.
when navigating between pages in Next.js
It doesn't happen on every request though. Probably something to do with that cache?
I even tried out using the createServerContext
to get this working consistently when setting it only in the layout, but then I found out in the react github issues that it will be deprecated and I should not use it. :slightly_frowning_face:
All in all - this feels somewhat hackish and I don't exactly like using it this way. In my opinion react/next.js developers should provide some additional functionality within the framework, that helps with setting and getting selected locales.
Also, would it be possible to somehow use the currently experimental dependency tree crawling?
Seems like it mostly works but I noticed that if I use setI18n only in the layout.tsx it sometimes triggers server side error: Error: Lingui for RSC is not initialized. Use setI18n() first in root of your RSC tree. when navigating between pages in Next.js
May be because of race-condition? As far as i remember, layouts are executed after page. So you need to initialize i18n in page not in a layout.
Also, would it be possible to somehow use the currently experimental dependency tree crawling(https://lingui.dev/guides/message-extraction#dependency-tree-crawling-experimental)?
It should work. With next's app folder and layouts, it even could be more granular than with regular pages
I've been playing with lingui and the app router for the last couple of days and it seems that if you need to translations in both, pages and layouts, you have to basically instance two i18n contexts. One for the page, and another one for the layout. Otherwise the context might be null sometimes, dependening if you do a full refresh or a client navigation.
This seems to be aligned with the approach suggested here for i18next, were they basically instance a new context on every useTranslation() call (for server components).
This seems very wasteful, but also doesn't look like there is any workaround...
@marcmarcet could you create a PR with improvements you mentioned in this repo? https://github.com/thekip/nextjs-lingui-rsc-poc
@thekip not sure if it's going to be helpful, but this is what iยดm playing with:
https://github.com/thekip/nextjs-lingui-rsc-poc/pull/2/files
As is it right now, the app crashes when navigating from parent to child page. Uncomenting the await useLinguiSSR(params.locale)
on every page ensures there is a context available at all times.
if we could find a way to get the locale in the Trans component, then everything would work out of the box without having to add await useLinguiSSR(params.locale)
to each page.
Also notice I added an extra layout in app/[locale], so the LinguiProvider can be shared with all client components, regardless of the page.
Hi guys. want to ask. Is there a guide on how to set up Lingui App router RSC support from the beginning?
Unfortunately, no, I don't use it in my own project because it's seems almost impossible to migrate a big productiuon application from pages to app router. And i don't have a capacity to investigate and write it up in my free time. But there is a lot of info already in this thread.