Skip to content

Next-i18next

The easiest way to translate your NextJs apps.

NextJS 13 버전 이상, /app 디렉토리에 적용하는 방법

next-i18next (및 기타 Next.js 관련 i18n 모듈) 에서 제공하는 멋진 기능은 여기여기에 설명된 것과 같이 이 새로운 앱 디렉토리 설정에 적합하지 않습니다.

INFORMATION

따라서 더 이상 next-i18next가 필요하지 않습니다!

새로운 접근법

이 섹션에서는 i18next, react-i18nexti18next-resources-to-backend를 사용하여 새 앱 디렉토리를 국제화하는 방법을 알아봅니다.

yarn add i18next react-i18next i18next-resources-to-backend

폴더 구조

.
└── app
    └── [lng]
        ├── second-page
        |   └── page.js
        ├── layout.js
        └── page.js

[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라는 새 파일을 생성해 보겠습니다:

export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de']

그런 다음 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 패키지를 설치하고,

npm install 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 형태로 적용한다:

{
  "title": "Hi there!",
  "to-second-page": "To second page"
}

이제 우리 페이지에서 이를 사용할 준비가 되었습니다.

서버 페이지는 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

지금까지 우리는 서버 측 페이지만 만들었습니다. 그렇다면 클라이언트 측 페이지는 어떤 모습일까요?

클라이언트 측 반응 구성 요소는 비동기화(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
                ├── client.js
                ├── FooterBase.js
                └── index.js

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

Favorite site

Legacy