Next-i18next
The easiest way to translate your NextJs apps.
NextJS 13 버전 이상, /app
디렉토리에 적용하는 방법
next-i18next (및 기타 Next.js 관련 i18n 모듈) 에서 제공하는 멋진 기능은 여기 및 여기에 설명된 것과 같이 이 새로운 앱 디렉토리 설정에 적합하지 않습니다.
INFORMATION |
따라서 더 이상 next-i18next가 필요하지 않습니다! |
새로운 접근법
이 섹션에서는 i18next, react-i18next 및 i18next-resources-to-backend를 사용하여 새 앱 디렉토리를 국제화하는 방법을 알아봅니다.
폴더 구조
[lng]
디렉토리로 언어 경로가 지정된다.
/app/[lng]/layout.js
file:
import { dir } from 'i18next'
const languages = ['en', 'de']
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>
{children}
</body>
</html>
)
}
/app/[lng]/page.js
file:
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi there!</h1>
<Link href={`/${lng}/second-page`}>
second page
</Link>
</>
)
}
/app/[lng]/second-page/page.js
file:
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi from second page!</h1>
<Link href={`/${lng}`}>
back
</Link>
</>
)
}
언어 감지
이제 http://localhost:3000/en 또는 http://localhost:3000/de 로 이동하면 내용이 표시되어야 하며,
두 번째 페이지와 그 뒤로 연결되는 링크도 작동해야 하지만 http://localhost:3000 으로 이동하면 404 오류를 반환합니다.
이 문제를 해결하기 위해 Next.js 미들웨어를 만들고 약간의 코드를 리팩토링 하겠습니다.
먼저 app/i18n/settings.js
라는 새 파일을 생성해 보겠습니다:
그런 다음 app/[lng]/layout.js
파일을 조정합니다:
import { dir } from 'i18next'
import { languages } from '../i18n/settings'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>
{children}
</body>
</html>
)
}
accept-language 패키지를 설치하고,
마지막으로 /middleware.js
파일을 만듭니다:
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
export function middleware(req) {
let lng
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
if (!lng) lng = fallbackLng
// Redirect if lng in path is not supported
if (
!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith('/_next')
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
}
if (req.headers.has('referer')) {
const refererUrl = new URL(req.headers.get('referer'))
const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
const response = NextResponse.next()
if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
return response
}
return NextResponse.next()
}
루트 경로(/
)로 이동하면 이제 마지막으로 선택한 언어의 쿠키가 이미 있는지 확인합니다.
폴백에서는 Accept-Language 헤더를 확인하고 마지막 폴백은 정의된 폴백 언어입니다.
감지된 언어는 해당 페이지로 리디렉션하는 데 사용됩니다.
Server-Side useTranslation
app/i18n/index.js
파일에서 i18next를 준비합시다.
여기서는 i18next 싱글톤을 사용하지 않고 각 useTranslation
호출마다 새 인스턴스를 생성합니다.
왜냐하면 컴파일하는 동안 모든 것이 병렬로 실행되는 것처럼 보인다. <- 확인 필요.
별도의 인스턴스를 사용하면 번역의 일관성이 유지됩니다.
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'
const initI18next = async (lng, ns) => {
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init(getOptions(lng, ns))
return i18nInstance
}
export async function useTranslation(lng, ns, options = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
}
}
app/i18n/settings.js
파일에 i18next 옵션을 추가합니다:
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de']
export const defaultNS = 'translation'
export const cookieName = 'i18next'
export function getOptions (lng = fallbackLng, ns = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns
}
}
번역 파일을 준비합시다:
.
└── app
└── i18n
└── locales
├── en
| ├── translation.json
| └── second-page.json
└── de
├── translation.json
└── second-page.json
각각의 번역파일은 다음과 같은 json 형태로 적용한다:
이제 우리 페이지에서 이를 사용할 준비가 되었습니다.
서버 페이지는 async
를 통해 useTranslation
응답을 기다릴 수 있습니다.
app/[lng]/page.js
file:
import Link from 'next/link'
import { useTranslation } from '../i18n'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng)
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
</>
)
}
언어 전환기
이제 Footer 구성 요소에서 언어 전환기를 정의해 보겠습니다.
app/[lng]/components/Footer/index.js
file:
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
import { useTranslation } from '../../../i18n'
export const Footer = async ({ lng }) => {
const { t } = await useTranslation(lng, 'footer')
return (
<footer style={{ marginTop: 50 }}>
<Trans i18nKey="languageSwitcher" t={t}>
Switch from <strong>{{lng}}</strong> to:{' '}
</Trans>
{languages.filter((l) => lng !== l).map((l, index) => {
return (
<span key={l}>
{index > 0 && (' or ')}
<Link href={`/${l}`}>
{l}
</Link>
</span>
)
})}
</footer>
)
}
react-i18next의 Trans component 를 사용하면 된다.
그리고 해당 Footer 구성 요소를 페이지에 추가합니다.
app/[lng]/page.js
file:
import Link from 'next/link'
import { useTranslation } from '../i18n'
import { Footer } from './components/Footer'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng)
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
<Footer lng={lng}/>
</>
)
}
Client-Side useTranslation
- NextI18next:ClientSideUseTranslation 항목에 리팩토링한 전체코드 포함.
지금까지 우리는 서버 측 페이지만 만들었습니다. 그렇다면 클라이언트 측 페이지는 어떤 모습일까요?
클라이언트 측 반응 구성 요소는 비동기화(async
)할 수 없으므로 몇 가지 조정이 필요합니다.
app/i18n/client.js
파일을 생성:
'use client'
import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import { useCookies } from 'react-cookie'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages, cookieName } from './settings'
const runsOnServerSide = typeof window === 'undefined'
//
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
},
preload: runsOnServerSide ? languages : []
})
export function useTranslation(lng, ns, options) {
const [cookies, setCookie] = useCookies([cookieName])
const ret = useTranslationOrg(ns, options)
const { i18n } = ret
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLng === i18n.resolvedLanguage) return
setActiveLng(i18n.resolvedLanguage)
}, [activeLng, i18n.resolvedLanguage])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!lng || i18n.resolvedLanguage === lng) return
i18n.changeLanguage(lng)
}, [lng, i18n])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (cookies.i18next === lng) return
setCookie(cookieName, lng, { path: '/' })
}, [lng, cookies.i18next])
}
return ret
}
클라이언트 측에서는 일반적인 i18next 싱글톤이 괜찮습니다. 한 번만 초기화됩니다. 그리고 "일반적인" useTranslation 후크를 사용할 수 있습니다. 우리는 언어를 전달할 수 있도록 포장하기만 하면 됩니다.
서버 측 언어 감지에 맞추기 위해 i18next-browser-languagedetector를 사용하고 그에 따라 구성합니다.
또한 Footer 구성 요소의 두 가지 버전을 만들어야 합니다.
app/[lng]/components/Footer/FooterBase.js
file:
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
export const FooterBase = ({ t, lng }) => {
return (
<footer style={{ marginTop: 50 }}>
<Trans i18nKey="languageSwitcher" t={t}>
Switch from <strong>{{lng}}</strong> to:{' '}
</Trans>
{languages.filter((l) => lng !== l).map((l, index) => {
return (
<span key={l}>
{index > 0 && (' or ')}
<Link href={`/${l}`}>
{l}
</Link>
</span>
)
})}
</footer>
)
}
async
버전인 app/[lng]/comComponents/Footer/index.js
를 계속 사용하는 서버 측 부분:
import { useTranslation } from '../../../i18n'
import { FooterBase } from './FooterBase'
export const Footer = async ({ lng }) => {
const { t } = await useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
클라이언트 측 부분에서는 새로운 i18n/client
버전인 app/[lng]/comComponents/Footer/client.js
를 사용합니다.
'use client'
import { FooterBase } from './FooterBase'
import { useTranslation } from '../../../i18n/client'
export const Footer = ({ lng }) => {
const { t } = useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
클라이언트 측 페이지 app/[lng]/client-page/page.js
는 다음과 같습니다:
'use client'
import Link from 'next/link'
import { useTranslation } from '../../i18n/client'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'
export default function Page({ params: { lng } }) {
const { t } = useTranslation(lng, 'client-page')
const [counter, setCounter] = useState(0)
return (
<>
<h1>{t('title')}</h1>
<p>{t('counter', { count: counter })}</p>
<div>
<button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
<button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
</div>
<Link href={`/${lng}`}>
<button type="button">
{t('back-to-home')}
</button>
</Link>
<Footer lng={lng} />
</>
)
}
See also
- i18n
- react
- i18next
- react-i18next (react)
- next-i18next (nextjs)
- i18next-browser-languagedetector
- NextJS:Internationalization
- next-intl