i18n in next.js app directory

contability·2023년 6월 11일
3

다국어 처리 라이브러리.

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

사용 방법


1. Folder structure

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>
  )
}

2. Language detection

이제 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 헤더를 확인하여 마지막 대체 언어가 정의된 대체 언어가 있는지 확인한다.

감지된 언어는 적절한 페이지로 리다이렉션하는 데 사용된다.

3. i18n instrumentation

컴파일하는 동안 모든 것이 병렬로 실행되는 것처럼 보이기 때문에 여기서는 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

4. Language switcher

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

이제 푸터에 있는 버튼으로 언어 전환이 가능하다.

5. Client side

지금까지는 서버 측 페이지만 만들었는데 클라이언트 측 페이지는 어떤 모습일까?

클라이언트 측 리액트 컴포넌트는 '동기화'할 수 없기 때문에 몇 가지 조정이 필요하다.

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

💡 출처

i18n with Next.js 13 and app directory (an i18next guide)

0개의 댓글