Triển khai i18next client, server component (ok)
Step 1: Thực hiện 1 trang ở app
app\[lang]\layout.tsx
import { ReactNode } from 'react';
import LanguageSwitcher from '@/src/components/LanguageSwitcher'
interface RootLayoutProps {
children: ReactNode;
}
export default async function RootLayout({ children }: RootLayoutProps) {
return (
<>
<header className="flex justify-end p-4">
<LanguageSwitcher />
</header>
{children}
</>
);
}
app\[lang]\page.tsx
import ClientComponent from '@/src/components/ClientComponent';
import initI18next from '@/src/lib/i18n/server';
import { fallbackLng, languages } from '@/src/lib/i18n/settings';
export default async function Page({ params }: { params: { lang: string } }) {
const {lang} = await params;
const glang = languages.includes(lang) ? lang : fallbackLng;
const i18n = await initI18next(glang, 'common');
const t = i18n.getFixedT(lang, 'common');
console.log('Server: lang=', glang, 't(hello)=', t('hello'));
return (
<>
<ClientComponent lang={glang} />
<h1>{t('hello')}</h1>
</>
);
}
app\layout.tsx
import { ReactNode } from 'react';
import LanguageSwitcher from '@/src/components/LanguageSwitcher'
interface RootLayoutProps {
children: ReactNode;
}
export default async function RootLayout({ children }: RootLayoutProps) {
return (
<>
<header className="flex justify-end p-4">
<LanguageSwitcher />
</header>
{children}
</>
);
}
src\components\ClientComponent.tsx
'use client';
import { useEffect, useState } from 'react';
import i18n from '@/src/lib/i18n/client';
import { useTranslation } from 'react-i18next';
export default function ClientComponent({ lang }: { lang: string }) {
const { t } = useTranslation('common');
const [isLanguageReady, setIsLanguageReady] = useState(i18n.language === lang);
useEffect(() => {
console.log('ClientComponent: lang=', lang, 'i18n.language=', i18n.language);
if (i18n.language !== lang) {
// Clear language detector cache to prevent interference
i18n.services.languageDetector?.cache?.clear?.();
i18n.changeLanguage(lang, (err) => {
if (err) console.error(`Failed to change language to ${lang}`, err);
i18n.loadNamespaces('common', (err) => {
if (err) console.error(`Failed to load namespace 'common' for ${lang}`, err);
setIsLanguageReady(true); // Mark language as ready
});
});
} else {
setIsLanguageReady(true); // Language already matches
}
}, [lang]);
console.log('ClientComponent rendering: t(hello)=', t('hello'));
return isLanguageReady ? <p>{t('hello')}</p> : null; // Render only when language is ready
}
src\components\LanguageSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useTransition } from 'react';
const locales = ['en', 'vi'];
export default function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const currentLang = pathname ? pathname.split('/')[1] : 'en';
const otherLang = locales.find((lng) => lng !== currentLang) || 'en';
const switchLang = () => {
if (!pathname) return;
const segments = pathname.split('/');
segments[1] = otherLang;
const newPath = segments.join('/');
console.log('LanguageSwitcher: Switching to:', newPath);
startTransition(() => {
router.push(newPath, { scroll: false });
router.refresh();
});
};
return (
<button
onClick={switchLang}
disabled={isPending}
className="px-4 py-2 rounded border bg-gray-100 hover:bg-gray-200 text-sm"
>
{otherLang.toUpperCase()}
</button>
);
}
src\lib\i18n\client.ts
'use client';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import resourcesToBackend from 'i18next-resources-to-backend';
import { getOptions } from './settings';
if (!i18n.isInitialized) {
i18n
.use(initReactI18next)
.use(LanguageDetector)
.use(
resourcesToBackend(async (lang: string, namespace: string) => {
try {
console.log(`Loading translation: ${lang}/${namespace}`);
const module = await import(`@/src/locales/${lang}/${namespace}.json`);
return module.default;
} catch (error) {
console.error(`Failed to load translation for ${lang}/${namespace}`, error);
return {};
}
})
)
.init({
...getOptions(),
detection: {
order: ['path'], // Only use path for language detection
caches: [], // Disable caching to avoid stale language
lookupFromPathIndex: 0, // Extract language from first URL segment (e.g., /vi/)
},
ns: ['common'],
defaultNS: 'common',
});
i18n.on('languageChanged', (lng) => {
console.log('Language changed to:', lng, 'Resources:', i18n.services.resourceStore.data);
});
}
export default i18n;
src\lib\i18n\server.ts
import i18next from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getOptions } from './settings'
const initI18next = async (lng: string, ns?: string) => {
const i18nInstance = i18next.createInstance()
await i18nInstance
.use(
resourcesToBackend((lang: string, namespace: string) =>
import(`@/src/locales/${lang}/${namespace}.json`)
)
)
.init(getOptions(lng, ns))
return i18nInstance
}
export default initI18next
src\lib\i18n\settings.ts
export const fallbackLng = 'en'
export const languages = ['en', 'vi']
export const defaultNS = 'common'
export function getOptions(lng = fallbackLng, ns = defaultNS) {
return {
supportedLngs: languages,
fallbackLng,
lng,
ns,
defaultNS,
interpolation: {
escapeValue: false,
},
}
}
next.config.ts
const nextConfig = {
experimental: {
turbo: {
enabled: false,
},
},
};
export default nextConfig;
src\locales\en\common.json
{
"hello": "Hello Eng"
}
src\locales\vi\common.json
{
"hello": "Xin chào Việt Nam"
}
Step 2: Thực hiện 1 trang ở pages
app\[lang]\layout.tsx
import { ReactNode } from 'react';
import LanguageSwitcher from '@/src/components/LanguageSwitcher';
interface RootLayoutProps {
children: ReactNode;
params: { lang: string };
}
export default async function RootLayout({ children, params }: RootLayoutProps) {
return (
<>
<header className="flex justify-end p-4">
<LanguageSwitcher />
</header>
{children}
</>
);
}
app\[lang]\page.tsx
import ClientComponent from '@/src/components/ClientComponent';
import initI18next from '@/src/lib/i18n/server';
import { fallbackLng, languages } from '@/src/lib/i18n/settings';
export default async function Page({ params }: { params: { lang: string } }) {
const {lang} = await params;
const glang = languages.includes(lang) ? lang : fallbackLng;
const i18n = await initI18next(glang, 'common');
const t = i18n.getFixedT(lang, 'common');
console.log('Server: lang=', glang, 't(hello)=', t('hello'));
return (
<>
<ClientComponent lang={glang} />
<h1>{t('hello')}</h1>
</>
);
}
pages\[lang]\new-page.tsx
import ClientComponent from '@/src/components/ClientComponent';
import LanguageSwitcher from '@/src/components/LanguageSwitcher';
import initI18next from '@/src/lib/i18n/server';
import { fallbackLng, languages } from '@/src/lib/i18n/settings';
import { GetServerSideProps } from 'next';
interface NewPageProps {
lang: string;
serverTranslation: string;
}
export default function NewPage({ lang, serverTranslation }: NewPageProps) {
console.log('NewPage Server: lang=', lang, 'serverTranslation=', serverTranslation);
return (
<>
<header className="flex justify-end p-4">
<LanguageSwitcher />
</header>
<ClientComponent lang={lang} />
<h1>{serverTranslation}</h1>
</>
);
}
export const getServerSideProps: GetServerSideProps<NewPageProps> = async (context) => {
// Ensure lang is a string
const langParam = Array.isArray(context.params?.lang)
? context.params.lang[0] // Take first element if array
: context.params?.lang || fallbackLng;
const lang = languages.includes(langParam) ? langParam : fallbackLng;
const i18n = await initI18next(lang, 'common');
const t = i18n.getFixedT(lang, 'common');
const serverTranslation = t('hello');
console.log('NewPage getServerSideProps: lang=', lang, 't(hello)=', serverTranslation);
return {
props: {
lang,
serverTranslation,
},
};
};
src\components\ClientComponent.tsx
'use client';
import { useEffect, useState } from 'react';
import i18n from '@/src/lib/i18n/client';
import { useTranslation } from 'react-i18next';
import { fallbackLng } from '@/src/lib/i18n/settings';
export default function ClientComponent({ lang = fallbackLng }: { lang?: string }) {
const { t } = useTranslation('common');
const [isLanguageReady, setIsLanguageReady] = useState(i18n.language === lang);
useEffect(() => {
console.log('ClientComponent: lang=', lang, 'i18n.language=', i18n.language);
if (i18n.language !== lang) {
i18n.services.languageDetector?.cache?.clear?.();
i18n.changeLanguage(lang, (err) => {
if (err) console.error(`Failed to change language to ${lang}`, err);
i18n.loadNamespaces('common', (err) => {
if (err) console.error(`Failed to load namespace 'common' for ${lang}`, err);
setIsLanguageReady(true);
});
});
} else {
setIsLanguageReady(true);
}
}, [lang]);
console.log('ClientComponent rendering: t(hello)=', t('hello'));
return isLanguageReady ? <p>{t('hello')}</p> : null;
}
src\components\LanguageSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useTransition } from 'react';
const locales = ['en', 'vi'];
export default function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const currentLang = pathname ? pathname.split('/')[1] : 'en';
const otherLang = locales.find((lng) => lng !== currentLang) || 'en';
const switchLang = () => {
if (!pathname) return;
const segments = pathname.split('/');
segments[1] = otherLang;
const newPath = segments.join('/');
console.log('LanguageSwitcher: Switching to:', newPath);
startTransition(() => {
router.push(newPath, { scroll: false });
});
};
return (
<button
onClick={switchLang}
disabled={isPending}
className="px-4 py-2 rounded border bg-gray-100 hover:bg-gray-200 text-sm"
>
{otherLang.toUpperCase()}
</button>
);
}
src\lib\i18n\client.ts
'use client';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import resourcesToBackend from 'i18next-resources-to-backend';
import { getOptions } from './settings';
if (!i18n.isInitialized) {
i18n
.use(initReactI18next)
.use(LanguageDetector)
.use(
resourcesToBackend(async (lang: string, namespace: string) => {
try {
console.log(`Loading translation: ${lang}/${namespace}`);
const module = await import(`@/src/locales/${lang}/${namespace}.json`);
return module.default;
} catch (error) {
console.error(`Failed to load translation for ${lang}/${namespace}`, error);
return {};
}
})
)
.init({
...getOptions(),
detection: {
order: ['path'], // Only use URL path
caches: [], // Disable caching
lookupFromPathIndex: 0,
},
ns: ['common'],
defaultNS: 'common',
});
i18n.on('languageChanged', (lng) => {
console.log('Language changed to:', lng, 'Resources:', i18n.services.resourceStore.data);
});
}
export default i18n;
src\lib\i18n\server.ts
import i18next from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getOptions } from './settings'
const initI18next = async (lng: string, ns?: string) => {
const i18nInstance = i18next.createInstance()
await i18nInstance
.use(
resourcesToBackend((lang: string, namespace: string) =>
import(`@/src/locales/${lang}/${namespace}.json`)
)
)
.init(getOptions(lng, ns))
return i18nInstance
}
export default initI18next
src\lib\i18n\settings.ts
export const fallbackLng = 'en'
export const languages = ['en', 'vi']
export const defaultNS = 'common'
export function getOptions(lng = fallbackLng, ns = defaultNS) {
return {
supportedLngs: languages,
fallbackLng,
lng,
ns,
defaultNS,
interpolation: {
escapeValue: false,
},
}
}
Source code (kiểm tra đã hoạt động tốt app và pages)
Step 3: Để tạo một ClientComponent không sử dụng 'use client'
mà vẫn hỗ trợ đa ngôn ngữ (i18n)
'use client'
mà vẫn hỗ trợ đa ngôn ngữ (i18n)Để tạo một ClientComponent không sử dụng 'use client'
mà vẫn hỗ trợ đa ngôn ngữ (i18n), bạn cần:
Không sử dụng hook như
useTranslation()
(vì hook chỉ dùng trong client component).Truyền dữ liệu dịch (
t
) từ server xuống dưới dạng prop.Hoặc tạo một function component async (server component) mà không cần hook.
src\components\TranslatedComponent.tsx
interface TranslatedComponentProps {
t: (key: string) => string;
}
export default function TranslatedComponent({ t }: TranslatedComponentProps) {
return (
<div className="p-4">
<h2>{t('hello')}</h2>
</div>
);
}
app\[lang]\page.tsx
import ClientComponent from '@/src/components/ClientComponent';
import TranslatedComponent from '@/src/components/TranslatedComponent';
import initI18next from '@/src/lib/i18n/server';
import { fallbackLng, languages } from '@/src/lib/i18n/settings';
export default async function Page({ params }: { params: { lang: string } }) {
const {lang} = await params;
const glang = languages.includes(lang) ? lang : fallbackLng;
const i18n = await initI18next(glang, 'common');
const t = i18n.getFixedT(glang, 'common');
console.log('Server: lang=', glang, 't(hello)=', t('hello'));
return (
<>
<ClientComponent lang={glang} />
<TranslatedComponent t={t} />
<h1>{t('hello')}</h1>
</>
);
}
Cách chuyển localhost:3000 sang localhost:3000/en
app\page.tsx
import Image from "next/image";
import { redirect } from 'next/navigation';
import { fallbackLng, languages } from '@/src/lib/i18n/settings';
export default async function Home({ params }: { params: { lang: string } }) {
const {lang} = await params;
if (!languages.includes(lang)) {
redirect(`/${fallbackLng}`);
}
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
HomePage
</div>
);
}
Last updated
Was this helpful?