[Next.js] i18n 적용하기 with App routing

Hwang Won Tae·2024년 2월 5일
2

Front-end

목록 보기
9/9
post-thumbnail
post-custom-banner

👋 Intro

취업하고 바쁘게 지내던 중 이제 조금 적응하고 시간을 효율적으로 사용할 수 있을 것 같아 간만에 글을 작성한다.

회사는 Vue를 사용하는데, 왜 갑자기 Next.js일까?
사내 테스트로 AI Generator Tools를 사용해 보면서 대부분의 새로운 서비스는 React를 기반하여 제공된다는 것을 깨달았다.

점유율이 가장 높은 서비스를 다룰 줄 알아야 기존 서비스를 넓은 시야로 바라볼 수 있을 것 같다.

📌 Check Point

Rendering

기존에는 CSR 서비스를 제공했기 때문에 클라이언트 측에서 브라우저의 Web API를 사용하여 locale을 가져왔다.
-> navigator.language

하지만 Next.js는 SSR이기 때문에 클라이언트에서 정보를 받기 전에 미리 보여주어야 한다.
렌더링 이후 locale을 설정하는 것은 UX를 고려하였을 때 좋지 않다.

따라서 클라이언트가 서버에 페이지를 요청할 때, 서버는 header의 Accept-Language를 확인하고 해당 locale로 설정하려고 한다.

App routing

Next.js가 App routing을 제공하면서 기존 i18n 라이브러리인 next-i18next이 적합하지 않다.

관련 문서

따라서 Article - next-app-dir-i18n 를 참고하여 진행해보자.

⚡ Locale strategies

Next.js에서는 locale의 두 가지 방식을 장려한다.

  • Sub-path Routing
  • Domain Routing

Sub-path Routingexample.com/koexample.com/en과 같은 구조이고,
Domain Routingexample.koexample.en과 같은 구조이다.

필자는 Sub-path Routing 방식으로 진행하고 글을 작성하고자 한다.

🚀 Get started

Installation

npm install i18next react-i18next i18next-resources-to-backend accept-language

or

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

Folder structure

.
└── app
    ├── [lng]
    |   └── [home]
    |       ├── head.tsx
    |       └── pages.tsx
    ├── layout.ts
    └── page.ts

Try it!

  1. i18n 설정
  • 필자는 ko를 기본 언어로 설정하고 진행하였다.

    만약, 하나의 json 파일에 모든 번역을 적용할 것이라면 namespace를 고정해주면 된다.

export const fallbackLng = 'ko';
export const languages = [fallbackLng, 'en'];
export const defaultNS = 'home';
export const cookieName = 'i18next';

export function getOptions(
  lng = fallbackLng,
  ns: string | string[] = defaultNS
) {
  return {
    supportedLngs: languages,
    fallbackLng,
    lng,
    fallbackNS: defaultNS,
    defaultNS,
    ns,
  };
}

// i18n/settings.ts
  • i18n을 init하는 index.ts 파일을 생성한다. 번역을 호출할 때마다 새로운 인스턴스를 만들어 준다.

이는 컴파일하는 동안 모든 것이 병렬처럼 실행되는 것처럼 보인다. 따라서 번역이 일관되게 유지된다.

import { createInstance, Namespace, FlatNamespace, KeyPrefix } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next/initReactI18next';
import { FallbackNs } from 'react-i18next';

import { getOptions } from '@/i18n/settings';

const initI18next = async (lng: string, ns: string | string[]) => {
  const i18nInstance = createInstance();
  await i18nInstance
    .use(initReactI18next)
    .use(
      resourcesToBackend(
        (language: string, namespace: string) =>
          import(`./locales/${language}/${namespace}.json`)
      )
    )
    .init(getOptions(lng, ns));
  return i18nInstance;
};

export async function useTranslation<
  Ns extends FlatNamespace,
  KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined,
>(lng: string, ns?: Ns, options: { keyPrefix?: KPrefix } = {}) {
  const i18nextInstance = await initI18next(
    lng,
    Array.isArray(ns) ? (ns as string[]) : (ns as string)
  );
  return {
    t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
    i18n: i18nextInstance,
  };
}

// i18n/index.ts

client에서 사용할 useTranslation hook을 생성한다.

'use client';

import { useEffect, useState } from 'react';
import i18next, { FlatNamespace, KeyPrefix } from 'i18next';
import {
  initReactI18next,
  useTranslation as useTranslationOrg,
  UseTranslationOptions,
  UseTranslationResponse,
  FallbackNs,
} 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 '@/i18n/settings';

const runsOnServerSide = typeof window === 'undefined';

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(
    resourcesToBackend(
      (language: string, namespace: string) =>
        import(`./locales/${language}/${namespace}.json`)
    )
  )
  .init({
    ...getOptions(),
    lng: 'ko',
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? languages : [],
  });

export function useTranslation<
  Ns extends FlatNamespace,
  KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined,
>(
  lng: string,
  ns?: Ns,
  options?: UseTranslationOptions<KPrefix>
): UseTranslationResponse<FallbackNs<Ns>, KPrefix> {
  1;
  const [cookies, setCookie] = useCookies([cookieName]);
  const ret = useTranslationOrg(ns, options);
  const { i18n } = ret;
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng);
  } else {
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage);
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return;
      setActiveLng(i18n.resolvedLanguage);
    }, [activeLng, i18n.resolvedLanguage]);
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return;
      i18n.changeLanguage(lng);
    }, [lng, i18n]);
    useEffect(() => {
      if (cookies.i18next === lng) return;
      setCookie(cookieName, lng, { path: '/home' });
    }, [lng, cookies.i18next]);
  }
  return ret;
}


// i18n/client.ts
  1. middleware 설정

cookie가 있다면 해당 언어로 적용하고, 없다면 headers의 Accept-Language를 가져온다.

import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from '.@/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()
}

// middleware.ts
  1. locale 파일 생성
{
  "title": "Next.js i18n 적용하기",
  "desc": "with App Routing",
  "link": "{{link}}로 이동"
}

// i18n/locales/ko/home.json
{
  "title": "Implementing Next.js i18n",
  "desc": "with App Routing",
  "link": "Go to {{link}}"
}

// i18n/locales/en/home.json
  1. i18n 적용

RootLayoutlng params를 추가한다.

import { languages } from '@/i18n/settings';

export const generateStaticParams = async () => {
  return languages.map((lng) => ({ lng }));
};

const RootLayout = ({
  children,
  params: { lng },
}: RootLayoutProps & PropsLanguage) => {
  return (
    <html lang={lng} dir={dir(lng)}>
      <body>
        <Providers>
          <Layout>{children}</Layout>
        </Providers>
      </body>
    </html>
  );
};

export default RootLayout;

// app/layout.tsx

만약 lng params가 없을 경우 default language 경로로 redirection한다.
필자는 기본 경로를 /home으로 설정했다.

import { redirect } from 'next/navigation';

import { fallbackLng, languages } from '@/i18n/settings';

export default async function Page({ params: { lng } }: { params: { lng: string }) {
  if (languages.indexOf(lng) < 0) lng = fallbackLng;
  redirect(`/${lng}/home`);
}

// app/page.tsx

index와 client에서 각각 가져와 localenamespace를 지정 후 테스트한다.

import { useTranslation } from '@/i18n';

export default async function Head({
  params: { lng },
}: {
  params: {
    lng: string;
  };
}) {
  const { t } = await useTranslation(lng, 'home');

  return (
    <>
      <title>{t('title')}</title>
      <meta name="description" content={t('desc')} />
    </>
  );
}

// app/[lng]/[home]/head.tsx
import { useTranslation } from '@/i18n/client'

export default function Page({ params: { lng } }: {
  params: {
    lng: string;
  };
}) {
  const { t } = useTranslation(lng, 'home')
  return (
    <>
      <main>
      	<div>{t('title')}</div>
      	<div>{t('desc')}</div>
        {languages.filter((l) => lng !== l).map((l, index) => {
          return (
            <span key={l}>
              {index > 0 && (' or ')}
              <Link href={`/${l}/home`}>{t('link', { link: l })}</Link>
            </span>
          )
        })}
      </main>
    </>
  )
}

// app/[lng]/[home]/page.tsx

Preview

📕 Reference

profile
For me better than yesterday
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 5월 12일

혹시 Head의 용도는 무엇인가요??

답글 달기
comment-user-thumbnail
2024년 5월 25일

react-cookie, i18next-browser-languagedetector도 설치해야하는군용

답글 달기