🌐

Next.js App Router での i18n 対応例

2024/02/08に公開

概要

色々あって参画している案件で Next.js の App Router への移行を主導する立場になりました。
なかでも i18n 対応が結構骨が折れた印象でした。

  • いろんな記事を見たが Server Component (以下、SC) に対応しているものが見当たらなかった
    • Root layout にて Provider でラップするとかはあった
      • けどそれだと Client Component (以下、CC) になるのでは(実際にハンズオンで確認まではしてないけど)
  • Next.js の公式ドキュメントで SC の対応例は掲載されていた
    • ただ、 スクラッチ実装なりの問題点はある[1]
      • react-i18next とか next-i18next を使った方がインターフェースの統一性が取れる
        • デファクトなパッケージであれば使用経験者も多い
      • i18nライブラリは複数形や文脈に応じた翻訳、フォールバック言語、カスタムフォーマッタなど翻訳に関する高度な機能を備えていることが多く、それと同等の機能をスクラッチ実装するのは非現実的である

以下の記事が一番参考になリましたが、 TypeScript を使っていなかったり、自分のプロダクトで実現したい仕様とは少し違っていたりしました。

https://locize.com/blog/next-app-dir-i18n/

このような経緯から、試験的にこの記事に記載した手段で実装することにしました。

最終的な実装は以下のリポジトリにあります。

https://github.com/k0kishima/nextjs-app-router-i18n-example

動作環境

ランタイム/パッケージ名 バージョン
Node.js 20.10.0
Next.js 14.1
i18next 23.8.2
next-i18next 15.2.0
react-i18next 14.0.5
negotiator 0.6.3

以降、実際にコードを書いていきます。

リポジトリ生成

便宜上、ESLint や Prettier がセットアップされているリポジトリを雛形として使います。

$ npx create-next-app@latest nextjs-app-router-i18n-example --use-npm --example "https://github.com/k0kishima/nextjs-scaffold"
$ cd nextjs-app-router-i18n-example/
$ npm i
$ npx husky install

i18n関連のパッケージのインストール

$ npm i next-i18next react-i18next i18next i18next-resources-to-backend negotiator
$ npm i --save-dev @types/negotiator

Dynamic Route の作成

Dynamic Routes は App Router の機能で [lang] のように角括弧で動的なセグメントを表現することができます。

https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes

今回は以下のような手順で、app ディレクトリの直下に [lang] というセグメントを追加しました。[2]

$ cd src/app
$ mkdir '[lang]'
$ mv favicon.ico globals.css layout.tsx page.tsx '[lang]'
$ cd ../../
$ tree src/app
src/app
└── [lang]
    ├── favicon.ico
    ├── globals.css
    ├── layout.tsx
    └── page.tsx

2 directories, 4 files

[locale] でもいいと思ったんですが以下の理由で [lang] にしました。

  • Next.js 公式ドキュメントでは lang が採用されていたり、 <html> の lang 属性とのメンタルモデルの一致もある
  • locale の方は言語だけでなく、地域や文化に特有のフォーマット(通貨、日付形式、数値形式、文字の並び替え順序など)を含む lang より大きな概念になる

ここまでの手順で、以下のようにサーバーを起動しても http://localhost:3000/ ではアクセスできなくなっているはずです。

$ npm run dev

一方、 http://localhost:3000/jahttp://localhost:3000/en であれば正常にレスポンスが得られると思います。

ページの簡素化

現在は Next.js のデフォルトのページが配置してあるので、最低限 i18n 関連の動作確認をできるように簡素化します。
トップページを以下に置き換えます。

src/app/[lang]/page.tsx
export default async function Home({ params }: { params: { lang: string } }) {
  const lang = params.lang;

  return (
    <main>
      <div className="m-5">{lang}</div>
    </main>
  );
}

グローバルなスタイル定義からも余計なものを消すため以下の内容に置き換えます。

src/app/[lang]/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

ルートレイアウトでもルーティング設定で取れるようになった lang パラメーターを HTML に設定するように修正します。

src/app/[lang]/layout.tsx
+import { dir } from 'i18next';
 import type { Metadata } from 'next';
 import { Inter } from 'next/font/google';
 import '@/app/globals.css';
@@ -11,11 +12,13 @@ export const metadata: Metadata = {
 
 export default function RootLayout({
   children,
+  params: { lang },
 }: Readonly<{
   children: React.ReactNode;
+  params: { lang: string };
 }>) {
   return (
-    <html lang="en">
+    <html lang={lang} dir={dir(lang)}>
       <body className={inter.className}>{children}</body>
     </html>
   );

ここまでの手順で、 http://localhost:3000/en にアクセスすると以下のようになります。

[lang] に相当する部分をパラメータとして取得した結果を画面に出力しているので、 http://localhost:3000/ja などURLを変更すると出力も変わります。

ミドルウェアの実装

続いてミドルウェアを実装します。

https://nextjs.org/docs/app/building-your-application/routing/middleware

まず i18n 関連の設定を保持するモジュールを作成します。

$ mkdir src/app/i18n/

ここでデフォルトの言語と、利用可能とする言語をそれぞれ指定します。

src/app/i18n/settings.ts
export const defaultLanguage = 'ja';
export const availableLanguages = [defaultLanguage, 'en'];

続いて、 app と同じ階層に ミドルウェアを設置します。

src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import Negotiator from 'negotiator';
import { defaultLanguage, availableLanguages } from '@/app/i18n/settings';

const getNegotiatedLanguage = (
  headers: Negotiator.Headers,
): string | undefined => {
  return new Negotiator({ headers }).language([...availableLanguages]);
};

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

export function middleware(request: NextRequest) {
  const headers = {
    'accept-language': request.headers.get('accept-language') ?? '',
  };
  const preferredLanguage = getNegotiatedLanguage(headers) || defaultLanguage;

  const pathname = request.nextUrl.pathname;
  const pathnameIsMissingLocale = availableLanguages.every(
    (lang) => !pathname.startsWith(`/${lang}/`) && pathname !== `/${lang}`,
  );

  if (pathnameIsMissingLocale) {
    if (preferredLanguage !== defaultLanguage) {
      return NextResponse.redirect(
        new URL(`/${preferredLanguage}${pathname}`, request.url),
      );
    } else {
      const newPathname = `/${defaultLanguage}${pathname}`;
      return NextResponse.rewrite(new URL(newPathname, request.url));
    }
  }

  return NextResponse.next();
}

このミドルウェアは以下のようなことをしています。

このミドルウェアでは、次のような処理が行われています:

  1. 言語のネゴシエーション

    • ユーザーのブラウザから送信された Accept-Language ヘッダーを解析し、利用可能な言語(availableLanguages で定義)の中から最も適合する言語(preferredLanguage)を選択します。
    • 適合する言語がない場合は、デフォルト言語(defaultLanguage)が選択されます。
  2. パスの言語コードの確認

    • 現在のURLのパスが、利用可能な言語で始まっているかをチェックします。
    • 利用可能な言語いずれも該当しない場合は、pathnameIsMissingLocaletrue になります。
  3. リダイレクトとリライトの処理

    • pathnameIsMissingLocaletrue の場合、以下の処理が行われます:
      • preferredLanguagedefaultLanguage と同じであれば、パスの先頭にデフォルトの言語をつけてリライトします。
      • preferredLanguagedefaultLanguage と異なる場合は、ユーザーを preferredLanguage を含む新しいパスにリダイレクトします。

今の設定だと下表のようになります。

URL 結果 補足
http://localhost:3000 http://localhost:3000/ja (リライト) ブラウザの言語設定が英語である場合は http://localhost:3000/en (リダイレクト)
http://localhost:3000/ja そのまま処理 利用可能な言語であるため(en など他の利用可能な言語の場合も同様)
http://localhost:3000/fr 404 Dynamic Routes としては有効だが利用可能な言語に該当しないため

サーバーサイドの翻訳処理を実装

まず、翻訳データを配置します。
最初はミニマルに以下のような感じにします。

$ mkdir -p src/app/i18n/locales/{ja,en}
src/app/i18n/locales/ja/translation.json
{
  "app_name": "Next.js App Router i18n デモ"
}
src/app/i18n/locales/en/translation.json
{
  "app_name": "Next.js App Router i18n demo"
}

続いて設定用のモジュールに以下を追加します。

src/app/i18n/settings.ts
 export const defaultLanguage = 'ja';
 export const availableLanguages = [defaultLanguage, 'en'];
+export const namespaces = ['translation'];
+
+export function getOptions(lng = defaultLanguage) {
+  return {
+    lng,
+    defaultNS: defaultLanguage,
+    fallbackLng: defaultLanguage,
+    fallbackNS: namespaces[0],
+    ns: namespaces,
+    supportedLngs: availableLanguages,
+  };
+}

namespaces は翻訳データを格納してあるファイル(拡張子が .json のファイル)の名前に対応しています。

getOptions は i18next を初期化する際に渡す設定を返す関数です。

続いてサーバー用のモジュールを実装します。

src/app/i18n/server.ts
import { createInstance } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { initReactI18next } from 'react-i18next/initReactI18next';
import { getOptions, defaultLanguage } from './settings';

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

export async function getTranslation(lang = defaultLanguage) {
  const i18nextInstance = await initI18next(lang);
  return {
    t: i18nextInstance.getFixedT(lang),
    i18n: i18nextInstance,
  };
}

これで、 http://localhost:3000/ja にアクセスすれば 「Next.js App Router i18n デモ」、 http://localhost:3000/en にアクセスすれば 「Next.js App Router i18n demo
」 がそれぞれ表示されるはずです。

クライアントサイドの翻訳処理を実装

App Router では src/app 以下のコンポーネントはデフォルトで SC になります。
CC で前節のメソッドを用いることはできませんので、クライアント用の実装を以下のとおり別途実装します。

src/app/i18n/client.tsx
'use client';

import React, {
  createContext,
  useContext,
  useEffect,
  useState,
  ReactNode,
} from 'react';
import i18next from 'i18next';
import {
  initReactI18next,
  useTranslation as useTranslationOrigin,
} from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import { getOptions } from './settings';

i18next
  .use(initReactI18next)
  .use(
    resourcesToBackend(
      (language: string, namespace: string) =>
        import(`./locales/${language}/${namespace}.json`),
    ),
  )
  .init(getOptions());

export function useTranslation(lang: string) {
  const { t, i18n } = useTranslationOrigin();

  useEffect(() => {
    const shouldChangeLanguage = lang && lang !== i18n.resolvedLanguage;
    if (shouldChangeLanguage) {
      i18n.changeLanguage(lang);
    }
  }, [lang, i18n]);

  return { t, i18n };
}

interface LanguageContextType {
  language: string;
  setLanguage: (language: string) => void;
}

const LanguageContext = createContext<LanguageContextType | undefined>(
  undefined,
);

interface LanguageProviderProps {
  children: ReactNode;
  initialLanguage: string;
}

export const LanguageProvider = ({
  children,
  initialLanguage,
}: LanguageProviderProps) => {
  const [language, setLanguage] = useState<string>(initialLanguage);

  return (
    <LanguageContext.Provider value={{ language, setLanguage }}>
      {children}
    </LanguageContext.Provider>
  );
};

export const useLanguage = (): LanguageContextType => {
  const context = useContext(LanguageContext);
  if (!context) {
    throw new Error('useLanguage must be used within a LanguageProvider');
  }
  return context;
};

これを適当な CC を作って動作確認します。

src/app/[lang]/_components/index.tsx
'use client';

import React from 'react';
import { useLanguage, useTranslation } from '@/app/i18n/client';

export default function ClientComponent() {
  const { language } = useLanguage();
  const { t } = useTranslation(language);

  return <p>CC: {t('app_name')}</p>;
}

上記をトップページで使ってみます。

src/app/[lang]/page.tsx
 import { getTranslation } from '@/app/i18n/server';
+import { LanguageProvider } from '@/app/i18n/client';
+import ClientComponent from './_components';
 
 export default async function Home({ params }: { params: { lang: string } }) {
   const lang = params.lang;
@@ -8,6 +10,10 @@ export default async function Home({ params }: { params: { lang: string } }) {
     <main>
       <div className="m-5">
         <h1>{t('app_name')}</h1>
+
+        <LanguageProvider initialLanguage={lang}>
+          <ClientComponent />
+        </LanguageProvider>
       </div>
     </main>
   );

これで、「CC: Next.js App Router i18n demo」 のような段落が追加で表示されているはずです。

翻訳ファイルの分割

i18n を導入するようなプロダクトでは一定ボリュームのコンテンツがあると思われます。
したがって、通常はひとつのファイルに全ての翻訳データをまとめるようなことをせず、ファイルを分割して保守性を高めると思います。

以下のファイルを追加してみましょう。

src/app/i18n/locales/ja/home.json
{
  "message": "やあ!"
}
src/app/i18n/locales/en/home.json
{
  "message": "Hi there!"
}

ファイルを追加したら設定用のモジュールの namespaces に追加します。

src/app/i18n/settings.ts
 export const defaultLanguage = 'ja';
 export const availableLanguages = [defaultLanguage, 'en'];
-export const namespaces = ['translation'];
+export const namespaces = ['translation', 'home'];
 
 export function getOptions(lng = defaultLanguage) {
   return {

参照は以下のように行います。

src/app/[lang]/_components/index.tsx
   const { language } = useLanguage();
   const { t } = useTranslation(language);
 
-  return <p>CC: {t('app_name')}</p>;
+  return <p>{t('home:message')}</p>;
 }

これで、以下のようにそれぞれ別ファイルの翻訳データを引けます。[3]

落穂拾い必要な箇所

  • サーバーサイドとクライアントサイドの間での似たようなコードが多いので極力DRYにしたい
    • 特に全く同じロケールをそれぞれ読みにいくのが無駄
  • 内部リンクに自動的に [lang] が付与されるわけではない
    • 公式ドキュメントだとテンプレートリテラルで書いてるけど今回の [lang] は階層的にトップレベルだから全ページに付くので毎回書くのは面倒
      • デフォルトの言語の場合は [lang] の部分がなくてもアクセスできるみたいな仕様(一応ここではミドルウェアのリライトでやってる)を実現する場合はかなり煩雑になるし
  • Dynamic Routes として [lang] が常にトップレベルに君臨し、その構造を利用した今のミドルウェアの実装も相まって app/src/[lang] と同じ階層に app/src/admin とか作ってもそっちにルーティングできない
    • 結果的にコードを全部 [lang] 以下に置かなければならない(一応ミドルウェアを改修すればルーティングできるが処理が煩雑になる)
  • クライアントコンポーネントの場合はページ再読み込みした時に一瞬フォールバック先の翻訳が出るのが違和感ある
  • Dynamic Routes を使わないケースのハンズオン

内部リンクによるページ遷移に関する考察

内部リンクによるページ遷移に関する考察

適当にページを追加してみます。

$ mkdir 'src/app/[lang]/projects/'
src/app/[lang]/projects/page.tsx
import { getTranslation } from '@/app/i18n/server';
import Link from 'next/link';

export default async function Projects({ params }: { params: { lang: string } }) {
  const lang = params.lang;
  const { t } = await getTranslation(lang);

  return (
    <main>
      <div className="m-5">
        <h1>{t('implementing')}</h1>

        <Link
          href={`/`}
          className="font-semibold text-blue-500 underline hover:text-blue-700"
        >
          {t('home')}
        </Link>
      </div>
    </main>
  );
}

追加したページへのリンクを既存のページへ設置します。

src/app/[lang]/page.tsx
 import { getTranslation } from '@/app/i18n/server';
 import { LanguageProvider } from '@/app/i18n/client';
 import ClientComponent from './_components';
+import Link from 'next/link';
 
 export default async function Home({ params }: { params: { lang: string } }) {
   const lang = params.lang;
@@ -14,6 +15,13 @@ export default async function Home({ params }: { params: { lang: string } }) {
         <LanguageProvider initialLanguage={lang}>
           <ClientComponent />
         </LanguageProvider>
+
+        <Link
+          href={`/projects`}
+          className="font-semibold text-blue-500 underline hover:text-blue-700"
+        >
+          {t('projects')}
+        </Link>
       </div>
     </main>
   );
src/app/i18n/locales/en/translation.json
 {
-  "app_name": "Next.js App Router i18n demo"
+  "app_name": "Next.js App Router i18n demo",
+  "implementing": "implementing...",
+  "home": "Home",
+  "projects": "Projects",
+  "switch_lang": "Switch to Japanese"
 }
src/app/i18n/locales/ja/translation.json
 {
-  "app_name": "Next.js App Router i18n デモ"
+  "app_name": "Next.js App Router i18n デモ",
+  "implementing": "実装中...",
+  "home": "ホーム",
+  "projects": "プロジェクト",
+  "switch_lang": "英語に切り替え"
 }

これを操作してみます。

リンクを設置する際に Dynamic Routes ([lang]の部分) がついていないので言語の設定がページ間で引き継げていません。

対処法1: Dynamic Routes のセグメントに言語を明示する

掲題の通り、リンクを修正してみます。
ついでに言語の切り替え用の簡易リンクも入れます(翻訳は前節で追加済み)

src/app/[lang]/page.tsx
         </LanguageProvider>
 
         <Link
-          href={`/projects`}
+          href={`/${lang}/projects`}
           className="font-semibold text-blue-500 underline hover:text-blue-700"
         >
           {t('projects')}
         </Link>
+
+        <br />
+
+        <Link
+          href={`/${lang === 'en' ? 'ja' : 'en'}`}
+          className="font-semibold text-blue-500 underline hover:text-blue-700"
+        >
+          {t('switch_lang')}
+        </Link>
       </div>
     </main>
   );
src/app/[lang]/projects/page.tsx
         <h1>{t('implementing')}</h1>
 
         <Link
-          href={`/`}
+          href={`/${lang}`}
           className="font-semibold text-blue-500 underline hover:text-blue-700"
         >
           {t('home')}

これで意図した動作になります。

ただ、これだと常に [lang] の部分がパス中に含まれるので、デフォルトの言語の場合は [lang] の部分は省略するといった要件があった場合は仕様とギャップが出ます。

まず、Cookie 操作の簡便のため以下のパッケージをインストールします。

$ npm i js-cookie
$ npm i --save-dev @types/js-cookie

続いて、ミドルウェアにて言語設定を Cookie に保存するようにします。

src/app/i18n/settings.ts
 export const defaultLanguage = 'ja';
 export const availableLanguages = [defaultLanguage, 'en'];
 export const namespaces = ['translation', 'home'];
+export const cookieName = 'i18next';
 
 export function getOptions(lng = defaultLanguage) {
   return {
src/middleware.ts
 import { NextRequest, NextResponse } from 'next/server';
 import Negotiator from 'negotiator';
-import { defaultLanguage, availableLanguages } from '@/app/i18n/settings';
+import { defaultLanguage, availableLanguages, cookieName } from '@/app/i18n/settings';
 
 const getNegotiatedLanguage = (
   headers: Negotiator.Headers,
@@ -17,7 +17,10 @@ export function middleware(request: NextRequest) {
   const headers = {
     'accept-language': request.headers.get('accept-language') ?? '',
   };
-  const preferredLanguage = getNegotiatedLanguage(headers) || defaultLanguage;
+  const preferredLanguage =
+    request.cookies.get(cookieName)?.value ||
+    getNegotiatedLanguage(headers) ||
+    defaultLanguage;
 
   const pathname = request.nextUrl.pathname;
   const pathnameIsMissingLocale = availableLanguages.every(
@@ -35,5 +38,11 @@ export function middleware(request: NextRequest) {
     }
   }
 
+  if (!request.cookies.has(cookieName)) {
+    const response = NextResponse.next();
+    response.cookies.set(cookieName, preferredLanguage);
+    return response;
+  }
+
   return NextResponse.next();
 }

これだと Cookie が言語選択の際の最優先基準になるので、 Cookie に "ja" がセットされたらそれを変更しない限り http://localhost:3000/en のように URL をブラウザのアドレスバーに直接打ち込んでアクセスしても、日本語のままコンテンツが表示されます。

そこで、以下のような言語スイッチ用のUIを追加します。

src/app/[lang]/_components/language-switcher.tsx
'use client';

import Cookies from 'js-cookie';
import {
  cookieName,
  availableLanguages,
  defaultLanguage,
} from '@/app/i18n/settings';

export const LanguageSwitcher = ({ currentLanguage = defaultLanguage }) => {
  const handleLanguageChange = (
    event: React.ChangeEvent<HTMLSelectElement>,
  ) => {
    const newLocale = event.target.value;
    Cookies.set(cookieName, newLocale);
    window.location.href = '/';
  };

  return (
    <select
      value={currentLanguage}
      onChange={handleLanguageChange}
    >
      {availableLanguages.map((lang) => (
        <option key={lang} value={lang}>
          {lang.toUpperCase()}
        </option>
      ))}
    </select>
  );
};

export default LanguageSwitcher;

これをトップページに設置します。

src/app/[lang]/page.tsx
@@ -2,6 +2,7 @@ import { getTranslation } from '@/app/i18n/server';
 import { LanguageProvider } from '@/app/i18n/client';
 import ClientComponent from './_components';
 import Link from 'next/link';
+import { LanguageSwitcher } from './_components/language-switcher';
 
 export default async function Home({ params }: { params: { lang: string } }) {
   const lang = params.lang;
@@ -22,6 +23,10 @@ export default async function Home({ params }: { params: { lang: string } }) {
         >
           {t('projects')}
         </Link>
+
+        <br />
+
+        <LanguageSwitcher currentLanguage={lang} />
       </div>
     </main>
   );

これを利用して、 href={/projects} のような Dynamic Routes を省いたリンクでも言語設定を維持できるようになります。

ただ、リダイレクトは追加のHTTPリクエストを発生させるためオーバヘッドが発生するという弊害はあります。

zod の i18n 対応

zod の i18n 対応

以下のパッケージが秀逸で、この記事での方式とも容易にインテグレーションできます。

https://github.com/aiji42/zod-i18n

zod-i18n をインストールします。

$ npm i zod-i18n-map

翻訳ファイルはパッケージのでもサイトから拝借します。

https://github.com/aiji42/zod-i18n/blob/main/examples/with-next-i18next/public/locales/en/zod.json

これを、 src/app/i18n/locales/en/zod.json に配置します。
(上記は英語ですが日本語も同様とします)

あとは以下のように設定をします。

src/app/i18n/client.tsx
@@ -14,6 +14,8 @@ import {
 } from 'react-i18next';
 import resourcesToBackend from 'i18next-resources-to-backend';
 import { getOptions } from './settings';
+import { z } from 'zod';
+import { zodI18nMap } from 'zod-i18n-map';
 
 i18next
   .use(initReactI18next)
@@ -25,6 +27,9 @@ i18next
   )
   .init(getOptions());
 
+z.setErrorMap(zodI18nMap);
+export { z };

これを以下のように使います。

@@ -11,12 +11,11 @@ import { ArrowRightIcon } from '@heroicons/react/20/solid';
 import Input from '@/components/ui/inputs';
 import { zodResolver } from '@hookform/resolvers/zod';
 import { useForm, FormProvider } from 'react-hook-form';
-import * as z from 'zod';
 import { signUpSchema } from '../_schemas';
 import { useState, useTransition } from 'react';
 import { toast } from 'sonner';
 import { buttonVariants } from '@/components/ui/buttons';
-import { useLanguage, useTranslation } from '@/app/i18n/client';
+import { useLanguage, useTranslation, z } from '@/app/i18n/client';
 
 const buttonClassName = buttonVariants(); 
  const form = useForm<z.infer<typeof signInSchema>>({
    resolver: zodResolver(signInSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

この記事のデモ用に用意したリポジトリとは全然関係ないもの[4]での例示になるので恐縮ですが、以下のように動作します。

最後に

Next.jsで決済の本書いたのでよかったら読んでください。

https://zenn.dev/k0kishima/books/f07cffba6e0fab

脚注
  1. この記事投稿時点ではスクラッチ実装でサーバーサイドの実装例のみ掲示されていました。後で内容が変わる可能性は高いと思います ↩︎

  2. '[lang]' のようにシングルクオートで囲んでるのはzshを使っているためです。これにより角括弧がメタ文字として処理されるのを防いでいます ↩︎

  3. namespace のデリミタは . ではなくて : なので注意してください( home:message )。 ↩︎

  4. https://github.com/k0kishima/nextjs-mnemonic-trainer ↩︎

Discussion