취업하고 바쁘게 지내던 중 이제 조금 적응하고 시간을 효율적으로 사용할 수 있을 것 같아 간만에 글을 작성한다.
회사는 Vue
를 사용하는데, 왜 갑자기 Next.js
일까?
사내 테스트로 AI Generator Tools
를 사용해 보면서 대부분의 새로운 서비스는 React
를 기반하여 제공된다는 것을 깨달았다.
점유율이 가장 높은 서비스를 다룰 줄 알아야 기존 서비스를 넓은 시야로 바라볼 수 있을 것 같다.
기존에는 CSR 서비스를 제공했기 때문에 클라이언트 측에서 브라우저의 Web API를 사용하여 locale
을 가져왔다.
-> navigator.language
하지만 Next.js
는 SSR이기 때문에 클라이언트에서 정보를 받기 전에 미리 보여주어야 한다.
렌더링 이후 locale
을 설정하는 것은 UX를 고려하였을 때 좋지 않다.
따라서 클라이언트가 서버에 페이지를 요청할 때, 서버는 header의 Accept-Language
를 확인하고 해당 locale
로 설정하려고 한다.
Next.js
가 App routing을 제공하면서 기존 i18n 라이브러리인 next-i18next
이 적합하지 않다.
관련 문서
따라서 Article - next-app-dir-i18n 를 참고하여 진행해보자.
Next.js
에서는 locale의 두 가지 방식을 장려한다.
Sub-path Routing은 example.com/ko
나 example.com/en
과 같은 구조이고,
Domain Routing은 example.ko
나 example.en
과 같은 구조이다.
필자는 Sub-path Routing 방식으로 진행하고 글을 작성하고자 한다.
npm install i18next react-i18next i18next-resources-to-backend accept-language
or
yarn add i18next react-i18next i18next-resources-to-backend accept-language
.
└── app
├── [lng]
| └── [home]
| ├── head.tsx
| └── pages.tsx
├── layout.ts
└── page.ts
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
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
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
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
i18n
적용
RootLayout
에lng
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에서 각각 가져와
locale
과namespace
를 지정 후 테스트한다.
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
혹시 Head의 용도는 무엇인가요??