Published on

ทำสองภาษาใน Next.js ที่เป็น App directory

ทำสองภาษาใน Next.js ที่เป็น App directory
ทำสองภาษาใน 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 ดังนี้ครับ

src/app/[lng]/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 ดังนี้ครับ

src/app/[lng]/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

โดยเราจะเพิ่มโค้ดลงไปดังนี้ครับ

src/app/i18n/settings.ts
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

เราจะเพิ่มโค้ดลงไปดังนี้ครับ

src/app/[lng]/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 ก็จะเลือกภาษานั้นให้ก่อนเลย
src/middleware.ts
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 ภาษา แต่ถ้าไม่มีก็ให้ดูจาก headers Accept-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

โดยจะใส่โค้ดดังนี้ครับ

src/app/i18n/index.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 จะเป็นดังนี้

src/app/i18n/locales/th/default.json
{
  "homePage": "หน้าแรก",
  "gotoProfile": "ไปที่หน้าโปรไฟล์"
}

ใน default.json ของ en จะเป็นดังนี้

src/app/i18n/locales/en/default.json
{
  "homePage": "Home Page",
  "gotoProfile": "Go to Profile Page"
}

ใน profile.json ของ th จะเป็นดังนี้

src/app/i18n/locales/th/profile.json
{
  "profilePage": "หน้าโปรไฟล์",
  "gotoHome": "ไปที่หน้าแรก"
}

ใน profile.json ของ en จะเป็นดังนี้

src/app/i18n/locales/en/profile.json
{
  "profilePage": "Profile Page",
  "gotoHome": "Go to Home Page"
}

แก้ไขหน้า page.tsx ของ src/app/[lng]

เราจะเรียกใช้งาน useTranslation ในหน้า page.tsx และแก้ไขข้อควาที่เรา hardcode ไว้ให้เป็นภาษา เจมส์จะแก้โค้ดเป็นดังนี้ครับ

src/app/[lng]/page.tsx
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 ด้วยเช่นกัน เราจะปรับเป็นแบบนี้ครับ

src/app/[lng]/profile/page.tsx
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

แล้วใส่โค้ดลงไปดังนี้ครับ

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

จากนั้นใส่โค้ดลงไปดังนี้ครับ

src/app/[lng]/client/page.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 โดยจะใส่โค้ดลงไปดังนี้ครับ

src/app/i18n/locales/en/client.json
{
  "clientPage": "Client Page",
  "gotoProfile": "Go to profile",
  "gotoHome": "Go to home"
}
src/app/i18n/locales/th/client.json
{
  "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)