다국어 처리 라이브러리.
next13이 발표 되면서 app routing 방식이 생겼고
기존 pages routing 방식에서의 i18n 접근 방식이 불가능해졌다.
그래서 나온 새로운 방식.
server components와 client components를 나눠서 설명한다.
yarn add i18next react-i18next i18next-resources-to-backend i18next-browser-languagedetector accept-language
i18next
react-i18next
i18next-resources-to-backend
i18next-browser-languagedetector
accept-language
language를 URL 매개변수로 사용하는 새 폴더 구조를 만드는 것으로 시작.
소위 동적 세그먼트라고 한다.
.
└── app
└── [lng]
├── second-page
| └── page.tsx
├── layout.tsx
└── page.tsx
app/[lng]/page.tsx
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.tsx
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi from second page!</h1>
<Link href={`/${lng}`}>
back
</Link>
</>
)
}
app/[lng]/layout.tsx
import { dir } from 'i18next'
const languages = ['en', 'kr']
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>
)
}
이제 http://localhost:3000/en
또는 http://localhost:3000/kr
로 이동하면 무언가가 표시되고 두 번째 페이지로 돌아가는 링크도 작동해야 하지만
http://localhost:3000
로 이동하면 404 오류가 반환된다.
이 문제를 해결하기 위해 Next.js 미들웨어를 생성하고 약간의 코드를 리팩토링이 필요하다.
app/i18n/settings.ts
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'kr']
app/[lng]/layout.tsx
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.ts
생성
yarn add accept-language
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
const cookieName = 'i18next'
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()
}
!https://locize.com/blog/next-13-app-dir-i18n/middleware.gif
이제 루트 경로 /
로 이동하면 마지막으로 선택한 언어로 된 쿠키가 이미 있는지 확인하고, Accept-Language
헤더를 확인하여 마지막 대체 언어가 정의된 대체 언어가 있는지 확인한다.
감지된 언어는 적절한 페이지로 리다이렉션하는 데 사용된다.
컴파일하는 동안 모든 것이 병렬로 실행되는 것처럼 보이기 때문에 여기서는 i18next 싱글톤을 사용하지 않고 사용 번역 호출 시마다 새 인스턴스를 생성한다.
별도의 인스턴스를 사용하면 번역의 일관성을 유지할 수 있다.
app/i18n/index.ts
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
* override language to use
* @param {*} lng
* override namespaces (string or array)
* @param {*} ns
* options
* @param {*} [options={}]
* @return {*}
*/
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.ts
에 옵션 추가
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'kr']
export const defaultNS = 'translation'
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
└── kr
├── translation.json
└── second-page.json
app/i18n/locales/en/translation.json
{
"title": "Hi there!",
"to-second-page": "To second page"
}
app/i18n/locales/kr/translation.json
{
"title": "안녕",
"to-second-page": "다음 페이지로 이동"
}
app/i18n/locales/en/second-page.json
:
{
"title": "Hi from second page!",
"back-to-home": "Back to home"
}
app/i18n/locales/kr/second-page.json
:
{
"title": "두 번째 페이지임",
"back-to-home": "메인으로"
}
app/[lng]/page.tsx
:
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>
</>
)
}
app/[lng]/second-page/page.tsx
:
import Link from 'next/link'
import { useTranslation } from '../../i18n'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng, 'second-page')
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}`}>
{t('back-to-home')}
</Link>
</>
)
}
!https://locize.com/blog/next-13-app-dir-i18n/app_de_1.jpg
footer에서 언어 전환을 구현해봄
app/[lng]/components/Footer/index.tsx
:
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 컴포넌트를 사용할 수도 있다.
app/i18n/locales/en/footer.json
:
{
"languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}
app/i18n/locales/kr/footer.json
:
{
"languageSwitcher": "<1>{{lng}}</1>에서 다음으로 전환합니다: "
}
페이지에 푸터 추가
app/[lng]/page.tsx
:
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}/>
</>
)
}
app/[lng]/second-page/page.tsx
:
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, 'second-page')
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}`}>
{t('back-to-home')}
</Link>
<Footer lng={lng}/>
</>
)
}
!https://locize.com/blog/next-13-app-dir-i18n/switcher.gif
이제 푸터에 있는 버튼으로 언어 전환이 가능하다.
지금까지는 서버 측 페이지만 만들었는데 클라이언트 측 페이지는 어떤 모습일까?
클라이언트 측 리액트 컴포넌트는 '동기화'할 수 없기 때문에 몇 가지 조정이 필요하다.
app/i18n/client.ts
'use client'
import { useEffect } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions } from './settings'
//
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'],
}
})
const runsOnServerSide = typeof window === 'undefined'
export function useTranslation(lng, ns, options) {
const ret = useTranslationOrg(ns, options)
const { i18n } = ret
if (runsOnServerSide && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (i18n.resolvedLanguage === lng) return
i18n.changeLanguage(lng)
}, [lng, i18n])
}
return ret
}
클라이언트 측에서는 일반적인 i18next 싱글톤은 ok고 한 번만 초기화된다.
그리고 일반적인 useTranslation 훅을 사용할 수 있다.
언어를 전달할 수 있도록 감싸기만 하면 된다.
서버 측 언어 감지와 일치시키기 위해 i18next-browser-languagedetector를 사용하고 적절하게 구성한다.
또한 두 가지 버전의 푸터 컴포넌트를 만들어야 한다.
.
└── app
└── [lng]
└── components
└── Footer
├── client.tsx
├── FooterBase.tsx
└── index.tsx
app/[lng]/components/Footer/FooterBase.tsx
:
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>
)
}
비동기 버전을 계속 사용하는 서버 측 부분,
app/[lng]/components/Footer/index.tsx
:
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]/components/Footer/client.tsx
:
'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.tsx
:
'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} />
</>
)
}
몇 가지 번역 리소스
app/i18n/locales/en/client-page.json
:
{
"title": "Client page",
"counter_one": "one selected",
"counter_other": "{{count}} selected",
"counter_zero": "none selected",
"back-to-home": "Back to home"
}
app/i18n/locales/kr/client-page.json
:
{
"title": "클라이언트 페이지",
"counter_one": "하나 선택됨",
"counter_other": "{{count}} 선택됨",
"counter_zero": "선택된 항목 없음",
"back-to-home": "메인 페이지로 돌아가기"
}
초기 페이지의 링크 - app/[lng]/page.tsx
:
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>
<br />
<Link href={`/${lng}/client-page`}>
{t('to-client-page')}
</Link>
<Footer lng={lng}/>
</>
)
}
번역 리소스:
app/i18n/locales/en/translation.json
:
{
"title": "Hi there!",
"to-second-page": "To second page",
"to-client-page": "To client page"
}
app/i18n/locales/kr/translation.json
:
{
"title": "ㅎㅇ!",
"to-second-page": "다음 페이지로",
"to-client-page": "클라이언트 페이지로"
}
결과적으로 이런 식으로 나와야 함
!https://locize.com/blog/next-13-app-dir-i18n/result.gif
💡 **예제 전체 코드**https://github.com/i18next/next-13-app-dir-i18next-example
💡 출처