- Published on
ทำสองภาษาใน Next.js ที่เป็น App directory
อันยองฮาเซโยวววว บทความวันนี้เจมส์จะมาเขียนเกี่ยวกับการทำสองภาษาของ Next.js ในแบบที่เป็น App directory ซึ่งเราจะใช้ i18next ครับ
เพื่อไม่ให้เป็นการเสียนาฬิกา แฮร่! เสียเวลา มาเริ่มกันเลยครับ
เริ่มกันเลย
สร้าง Project Next.js แบบ App Directory
เจมส์จะสร้างโปรเจคชื่อ example-next-i18next นะครับ
npx create-next-app@latest example-next-i18next
และเจมส์จะเลือกคำตอบ ดังคำตอบข้างล่างเน้อครับ
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … No
✔ Would you like to use Tailwind CSS? … No
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*
จากนั้นให้เข้าไปใน โปรเจค
cd example-next-i18next
จากนั้นเราจะมา install node_modules ที่จำเป็นต้องใช้กันก่อนเน้อครับ จะมี
- i18next เป็น library สำหรับแปลภาษาใน javascript
- react-i18next เป็น module เสริมสำหรับ i18next ที่ออกแบบมาเพื่อใช้งานกับ react
- i18next-resources-to-backend เป็น module เสริมสำหรับ i18next แปลง resource ไปเป็น i18next backend ให้ใช้งานได้
npm install --save i18next react-i18next i18next-resources-to-backend
สร้างไฟล์ page.tsx และ profile/page.tsx
ขั้นแรกเดี๋ยวเราลบไฟล์ใน src/app
ออกให้หมดก่อนครับ จากนั้นให้สร้าง directory ชื่อ [lng]
src
└── app
└── [lng]
จากนั้นสร้าง page.tsx
ใน [lng]
src
└── app
└── [lng]
└── page.tsx
โดยเราจะใส่โค้ดให้กับ page.tsx
ดังนี้ครับ
import Link from "next/link";
interface HomeProps {
params: {
lng: string;
};
}
const HomePage: React.FC<HomeProps> = (props) => {
const {
params: { lng },
} = props;
return (
<>
<h1>หน้าแรก</h1>
<Link href={`/${lng}/profile`}>ไปที่หน้าโปรไฟล์</Link>
</>
);
};
export default HomePage;
จากนั้นให้สร้าง directory ชื่อ profile
และสร้างไฟล์ชื่อ page.tsx
ใน diectory นั้น
src
└── app
└── [lng]
├── profile
| └── page.tsx
└── page.tsx
จากนั้นให้ใส่โค้ดลงไปใน profile/page.tsx
ดังนี้ครับ
import Link from "next/link";
interface ProfileProps {
params: {
lng: string;
};
}
const ProfilePage: React.FC<ProfileProps> = (props) => {
const {
params: { lng },
} = props;
return (
<>
<h1>หน้าโปรไฟล์</h1>
<Link href={`/${lng}`}>ไปที่หน้าแรก</Link>
</>
);
};
export default ProfilePage;
เพิ่ม settings.ts ใน src/app/i18n/settings.ts
เดี๋ยวเราจะมาเพิ่ม directory ชื่อ i18n ให้อยู่ใน src/app และเพิ่ม settings.ts
ให้อยู่ใน directory i18n ที่เราเพิ่ม
src
└── app
└── i18n
└── settings.ts
└── [lng]
├── profile
| └── page.tsx
└── page.tsx
โดยเราจะเพิ่มโค้ดลงไปดังนี้ครับ
export const fallbackLng = "th";
export const languages = [fallbackLng, "en"];
export const cookieName = "i18next";
export const defaultNS = "default";
export function getOptions(
lng = fallbackLng,
ns: string | string[] = defaultNS
) {
return {
// debug: true,
supportedLngs: languages,
// preload: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns,
// backend: {
// projectId: '01b2e5e8-6243-47d1-b36f-963dbb8bcae3'
// }
};
}
เพิ่มไฟล์ layout.tsx ใน src/app/[lng]
ขั้นตอนถัดมาเราจะเพิ่มไฟล์ layout.tsx
ใส่ใน directory src/app/[lng]
src
└── app
└── i18n
└── settings.ts
└── [lng]
├── profile
| └── page.tsx
└── page.tsx
└── layout.tsx
เราจะเพิ่มโค้ดลงไปดังนี้ครับ
import { dir } from "i18next";
import { languages } from "@/app/i18n/settings";
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }));
}
export default function RootLayout({
children,
params: { lng },
}: {
children: React.ReactNode;
params: {
lng: string;
};
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>{children}</body>
</html>
);
}
เพิ่ม middleware.ts ใน src
ขั้นตอนถัดมาเราจะเพิ่ม middleware.ts
เพื่อให้เวลาเข้าไปที่ http://localhost:3000 จะให้ redirect ไปที่ http://localhost:3000/th ก่อน แต่ก่อนที่เราจะเพิ่ม เราจะลง node_modules ชื่อ accept-language
กันก่อนครับ
npm install --save accept-language
accept-language ที่เราลงเราจะใช้สองอย่างคือ
- บอกว่าภาษาที่เรา Support มีอะไรบ้าง
- ดูว่า browser ที่เปิดตั้งค่าเป็นภาษาอะไร ถ้าเป็นภาษาที่เรา support ก็จะเลือกภาษานั้นให้ก่อนเลย
import acceptLanguage from "accept-language";
import { NextResponse, NextRequest } from "next/server";
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: NextRequest) {
if (
req.nextUrl.pathname.indexOf("icon") > -1 ||
req.nextUrl.pathname.indexOf("chrome") > -1
)
return NextResponse.next();
let lng: string | undefined | null;
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();
}
เมื่อเราลองเข้า browser แบบไม่ได้ระบุภาษา (http://localhost:3000) ในส่วนของ middelware จะเช็คว่ามี cookie
i18next
ไหม ถ้ามีก็เอาค่าจาก cookie มา set ภาษา แต่ถ้าไม่มีก็ให้ดูจาก headersAccept-Language
กำหนดภาษาไม่ได้อีกก็จะดูจาก fallbackLng ที่กำหนดไว้
ในตอนนี้เมื่อเราเข้า http://localhost:3000 จะ redirect ไปที่ http://localhost:3000/th ถ้า browser ที่เปิดตั้งค่าภาษาเริ่มต้นเป็น Thai
แต่ถ้า browser ที่เปิดตั้งค่าภาษาเป็น English เป็นค่าเริ่มต้น จะ redirect ไปที่ http://localhost:3000/en
เพิ่ม index.ts ใน src/app/i18n
เราจะเพิ่ม index.ts
ใส่ใน src/app/i18n โครงสร้างจะเป็นดังนี้ครับ
src
└── app
└── i18n
└── settings.ts
└── index.ts
└── [lng]
├── profile
| └── page.tsx
└── page.tsx
└── layout.tsx
└── middleware.ts
โดยจะใส่โค้ดดังนี้ครับ
import { createInstance, 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 "@/app/i18n/settings";
const initI18next = async (lng: string, ns: string | string[]) => {
// on server side we create a new instance for each render, because during compilation everything seems to be executed in parallel
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 | string[], 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,
};
}
เพิ่มไฟล์ภาษาของ en และ th
ขั้นตอนถัดไปเราจะมาเพิ่มไฟล์ภาษากันครับ โดยขั้นแรกให้สร้าง directory ชื่อ locales
และสร้าง directory en
และ th
ให้อยู่ใน locales
directory และสร้างไฟล์ชื่อ default.json
และ profile.json
ให้อยู่ใน directory en
และ directory th
ซึ่งโครงสร้างจะเป็นอย่างด้านล่างเลยครับ
src
└── app
└── i18n
└── locales
└── en
└── default.json
└── profile.json
└── th
└── default.json
└── profile.json
ใน default.json
ของ th จะเป็นดังนี้
{
"homePage": "หน้าแรก",
"gotoProfile": "ไปที่หน้าโปรไฟล์"
}
ใน default.json
ของ en จะเป็นดังนี้
{
"homePage": "Home Page",
"gotoProfile": "Go to Profile Page"
}
ใน profile.json
ของ th จะเป็นดังนี้
{
"profilePage": "หน้าโปรไฟล์",
"gotoHome": "ไปที่หน้าแรก"
}
ใน profile.json
ของ en จะเป็นดังนี้
{
"profilePage": "Profile Page",
"gotoHome": "Go to Home Page"
}
แก้ไขหน้า page.tsx ของ src/app/[lng]
เราจะเรียกใช้งาน useTranslation
ในหน้า page.tsx
และแก้ไขข้อควาที่เรา hardcode ไว้ให้เป็นภาษา เจมส์จะแก้โค้ดเป็นดังนี้ครับ
import Link from "next/link";
import { useTranslation } from "@/app/i18n";
interface HomeProps {
params: {
lng: string;
};
}
const HomePage: React.FC<HomeProps> = async (props) => {
const {
params: { lng },
} = props;
const { t } = await useTranslation(lng);
return (
<>
<h1>{t("homePage")}</h1>
<Link href={`/${lng}/profile`}>{t("gotoProfile")}</Link>
</>
);
};
export default HomePage;
ซึ่งเมื่อเราลองเข้า http://localhost:3000/th และ http://localhost:3000/en จะพบว่าสองหน้านี้ภาษาได้ถูกเปลี่ยนตาม url เรียบร้อยแล้ว
แก้ไขหน้า page.tsx ของ src/app/[lng]/profile
เราจะเรียกใช้งาน useTranslation
ในหน้า page.tsx
ที่อยู่ใน profile ด้วยเช่นกัน เราจะปรับเป็นแบบนี้ครับ
import Link from "next/link";
import { useTranslation } from "@/app/i18n";
interface ProfileProps {
params: {
lng: string;
};
}
const ProfilePage: React.FC<ProfileProps> = async (props) => {
const {
params: { lng },
} = props;
const { t } = await useTranslation(lng, "profile");
return (
<>
<h1>{t("profilePage")}</h1>
<Link href={`/${lng}`}>{t("gotoHome")}</Link>
</>
);
};
export default ProfilePage;
ถ้าหากลองเข้าด้วย http://localhost:3000/en แล้วลองเข้าด้วย http://localhost:3000/th จะพบว่าภาษาเปลี่ยนตามที่เราตั้งค่าเรียบร้อยแล้ว
ทุกอย่างเหมือนจะเรียบร้อย แต่....ถ้าหาก Page ที่เราเข้าเป็น client จะพบว่าจะเกิด Error ขึ้น เดี๋ยวเราลองมาทำ Page ที่เป็น Client กันครับ
สร้างไฟล์ client.ts ใน src/app/i18n
ก่อนอื่นเดี๋ยวเรามา install dependencies เพิ่มอีก 2 ตัวครับคือ react-cookie
และ i18next-browser-languagedetector
npm install --save react-cookie i18next-browser-languagedetector
เดี๋ยวเรามาสร้างไฟล์ client.ts
ขึ้นมาก่อนครับ ซึ่งเราจะเอาไว้ใช้ทำในส่วนของภาษาที่ใช้สำหรับ page ที่เป็น client site
src
└── app
└── i18n
└── client.ts
แล้วใส่โค้ดลงไปดังนี้ครับ
"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 LocizeBackend from 'i18next-locize-backend'
import LanguageDetector from "i18next-browser-languagedetector";
import { getOptions, languages, cookieName } from "./settings";
const runsOnServerSide = typeof window === "undefined";
// on client side the normal singleton is ok
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`)
)
)
// .use(LocizeBackend) // locize backend could be used on client side, but prefer to keep it in sync with server side
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
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> {
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: "/" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lng, cookies.i18next]);
}
return ret;
}
สร้าง Client Page
ให้เราสร้าง directory ชื่อ client
ขึ้นมา ก่อนครับให้อยู่ใน src/app/[lng]
และสร้าง page.tsx
ขึ้นมาครับ
src
└── app
└── [lng]
├── client
| └── page.tsx
├── profile
| └── page.tsx
└── page.tsx
└── layout.tsx
จากนั้นใส่โค้ดลงไปดังนี้ครับ
"use client";
import { useTranslation } from "@/app/i18n/client";
import Link from "next/link";
import { useEffect } from "react";
interface ClientProps {
params: {
lng: string;
};
}
const ClientPage: React.FC<ClientProps> = (props) => {
const {
params: { lng },
} = props;
const { t } = useTranslation(lng, "client");
// ใส่ useEffect และ use client เพื่อจำลองว่าเป็น client site
useEffect(() => {}, []);
return (
<>
<h1>{t("clientPage")}</h1>
<div>
<Link href={`/${lng}/profile`}>{t("gotoProfile")}</Link>
</div>
<div>
<Link href={`/${lng}`}>{t("gotoHome")}</Link>
</div>
</>
);
};
export default ClientPage;
สังเกตนิดนึงครับว่าตรง
useTranslation
เรา import จาก@/app/i18n/client
ซึ่งคือ client.ts ใน i18n ที่เราสร้างขึ้นมาครับ
จากนั้นให้เพิ่มไฟล์ client.json
ลงไปใน src/app/i18n/locales/en
และ src/app/i18n/locales/th
โดยจะใส่โค้ดลงไปดังนี้ครับ
{
"clientPage": "Client Page",
"gotoProfile": "Go to profile",
"gotoHome": "Go to home"
}
{
"clientPage": "หน้าไคลแอนท์",
"gotoProfile": "ไปที่หน้าโปรไฟล์",
"gotoHome": "ไปที่หน้าแรก"
}
จากนั้นลองเข้า http://localhost:3000/th/client หรือ http://localhost:3000/en/client จะพบว่าภาษาเปลี่ยนทั้งคู่เรียบร้อยแล้ว
ถ้าหากบทความนี้มีส่วนไหนผิดพลาดประการใดก็ขออภัยมา ณ ที่นี้ด้วยเน้อครับ หรือหากคุณผู้อ่านมีข้อสงสัยในส่วนไหนสามารถพิมพ์ถามในคอมเม้นท์ได้เน้อครับ ^^
Reference
i18n with Next.js 13/14 and app directory / App Router (an i18next guide)