Hello everyone,
I have come across an issue with the nature of how NextJS handles asynchronous functions in client components that prevents me from resolving this matter further:
I opted for using i18next and react-i18next for translations instead of next-intl due to the number of caveats with variables and namespaces, as well as lack of recent support from the developer's side. Additionally, I chose not to use locale-based routes such as example.com/[lng]/page and replace this detection functionality using cookies which are available on both server and client.
So far, I have managed to implement Server-Side Rendering (SSR) into this setup. It works perfectly, except when the language has to be switched:
To provide starting configurations for the client I have written this index.ts file:
//index.ts
import i18n from 'i18next'
// DO NOT INITIALISE AT CREATION!
export const i18nInstance = i18n.createInstance()
export const commonConfig = {
fallbackLng: 'en',
supportedLngs: ['en', 'pl', 'de', 'ru'],
ns: ['general', 'countries', 'intro', 'auth', 'components'],
defaultNS: 'general',
interpolation: { escapeValue: false }
}
With this config, the client (if not already initialised) initialises an i18n instance in client.ts and returns the instance for client use:
'use client'
import { i18nInstance, commonConfig } from './index'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import I18NextHttpBackend from 'i18next-http-backend'
if (!i18nInstance.isInitialized) {
i18nInstance
.use(initReactI18next)
.use(LanguageDetector)
.use(I18NextHttpBackend)
.init({
...commonConfig,
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json'
},
detection: {
order: ['localStorage', 'cookie', 'navigator'],
caches: ['localStorage', 'cookie']
}
})
}
export default i18nInstance
At the time of render, the server would initialise its own instance of i18next for hydration in a function to return the initialStore to be used to hydrate based on parameters provided from cookies and required namespaces. This logic is laid out in server.ts:
//server.ts
import i18next, { i18n as I18nInstance } from 'i18next'
import fsBackend from 'i18next-fs-backend'
import path from 'path'
const localesPath = path.join(process.cwd(), 'public', 'locales')
export async function getServerTranslations(locale: string, namespaces: string[] = ['general']) {
const i18nInstance: I18nInstance = i18next.createInstance()
try {
await i18nInstance
.use(fsBackend)
.init({
lng: locale,
fallbackLng: 'en',
supportedLngs: ['en', 'pl', 'de', 'ru'],
ns: namespaces,
defaultNS: 'general',
interpolation: { escapeValue: false },
backend: {
loadPath: path.join(localesPath, '{{lng}}', '{{ns}}.json'),
},
initImmediate: false,
})
} catch (err) {
console.error('[i18n] Error during i18next init:', err)
}
const storeData = i18nInstance.store?.data || {}
return {
t: i18nInstance.t.bind(i18nInstance),
initialStore: storeData,
locale,
}
}
To actually hydrate, the data from initialStore is given to a client component I18nProvider.tsx, which is meant to call an initialiser function where i18next hook useSSR() is called and the language to retrieve the namespaces in is meant to be changed to the one requested:
//I18nProvider
'use client'
import { I18nextProvider, useSSR } from 'react-i18next'
import i18n from '@/i18n/client'
// Internal useSSR component
function SSRInitializer({
children,
initialI18nStore,
locale
}: {
children: React.ReactNode
initialI18nStore: any
locale: string
}) {
useSSR(initialI18nStore, locale)
if (i18n.language !== locale) {
i18n.changeLanguage(locale)
}
return children;
}
export default function I18nProvider({
children,
locale,
initialI18nStore
}: {
children: React.ReactNode
locale: string
initialI18nStore: any
}) {
return (
<I18nextProvider i18n={i18n}>
<SSRInitializer
initialI18nStore={initialI18nStore}
locale={locale}
>
{children}
</SSRInitializer>
</I18nextProvider>
)
}
All of this is then wrapped in layout.tsx to provide the server with all the data to hydrate:
//excerpt of layout.tsx:
<div id="root">
<I18nProvider locale={locale} initialI18nStore={initialStore}>
{children}
</I18nProvider>
</div>
As mentioned before, the issue arises when the language is changed, via cookie or otherwise. By default, the server will always fetch English translations as fallback, so there will never be issues with hydration there.
When a language mismatch is detected, however, I18nProvider.tsx calls for i18n to change locale via i18n.changeLanguage(locale). This function is asynchronous, meaning children will render before this language change is registered and during hydration the client will not have the data for that new language, meaning it will fallback to English, but the page will render in the intended language, resulting in a hydration mismatch error:
Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
+Willkommen zurück!
-Welcome back!
<h1>{t('welcome')}</h1>
The solution lies in waiting for the language change to complete or pre-loading all languages in advance (something I would not want as I would like to publish my implementation as open-source later to create an easy way for other developers to work with i18next on NextJS, and this would be unfavourable for larger projects).
I cannot seem to tell the client to wait until language is changed before rendering children. Initially, I thought this would be an easy fix by changing the function to asynchronous and using await on i18n.changeLanguage(locale), but it proved impossible due to NextJS limitations.
What could I possibly do?
Fixed by updating SSRInitialiser in I18nProvider.tsx:
function SSRInitializer({
children,
initialI18nStore,
locale
}: {
children: React.ReactNode
initialI18nStore: any
locale: string
}) {
useSSR(initialI18nStore, locale)
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
if (i18n.language !== locale) {
i18n.changeLanguage(locale)
.then(() => {
setIsLoaded(true)
})
.catch(() => {
setIsLoaded(true)
})
} else {
setIsLoaded(true)
}
return () => {
};
}, [locale])
if (!isLoaded) {
return null;
}
return children;
}
What changed:
I decided to go back with the strategy where I use useEffect() to manage language changing. I have added a state variable to tell the app if the correct language is currently loaded, then inside useEffect(), a check if its loaded and subsequent asynchronous language change resolution within a client component using then() controllers.
Language now changes as expected.