多言語対応気をつけること(Dev環境エラーについて)

  • useTranslations() で得られる tReact Hook から返される関数

  • イベントハンドラ useCallbackuseEffect 内で直接 t を参照すると、React は その依存関係が変わったときに再計算する必要がある と考えます
  • 依存配列に入れていないと react-hooks/exhaustive-deps 警告が出ます

    • 翻訳関数 t は依存配列に入れる

    • イベントハンドラや useEffect 内で使うときも同じ

    • もし t を渡す関数を別で作る場合は、引数として渡す方法 も安全です:

    function showWorkerClick(tFunc: (key: string) => string) {
    alert(tFunc('worker.click'));
    }
    // 使用側
    const t = useTranslations();
    const handleClick = () => showWorkerClick(t);

    こうすれば依存配列の問題も起きません。


    💡 要は 「イベントハンドラやコールバック関数の中で使う変数は、必ず依存配列に入れるか引数で渡す」 というルールです。

    ### 使ってない変数があると怒られるし、それを消した後に、.nextをyarn devで入れ直すと

    npx eslint --fix .

    で起きるエラーは結構消える。

    あとは、高階関数と依存注入使えば大体うまく行く。大体。

import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { Locale, normalizePath, isPublicPlanPageURL, getLoginPagePath, getHomePagePath } from '@/utils/path';

const PUBLIC_FILE = /\.(.*)$/;
// 公開認証ページ
const PUBLIC_ROUTES = ['/auth/login', '/auth/register', '/auth/forgot-password', '/auth/reset-password'];

// next-intlのミドルウェアを初期化
const intlMiddleware = createIntlMiddleware({
  locales: routing.locales,
  defaultLocale: routing.defaultLocale,
  localePrefix: 'always',
});

export async function middleware(req: NextRequest) {
  // パスの末尾スラッシュを削除して正規化
  const pathname = normalizePath(req.nextUrl.pathname);
  // URLからロケールプレフィックスを抽出
  const locale = pathname.split('/')[1] as Locale;

  // --- ルート / の場合、ロケール判定 & リダイレクト ---
  if (pathname === '/') {
    const supportedLocales = routing.locales;
    const cookieLocale = req.cookies.get('locale')?.value as Locale | undefined;

    // Cookie > Accept-Language > デフォルトの順で最適なロケールを決定
    const matchedLocale: Locale =
      cookieLocale && supportedLocales.includes(cookieLocale)
        ? cookieLocale
        : (((req.headers.get('accept-language') || '')
            .split(',')
            .map((l) => l.split(';')[0].trim())
            .find((l) => supportedLocales.includes(l as Locale)) as Locale) ?? routing.defaultLocale);

    // 決定したロケールでリダイレクトし、Cookieに保存
    const url = req.nextUrl.clone();
    url.pathname = `/${matchedLocale}`;
    const res = NextResponse.redirect(url);
    res.cookies.set('locale', matchedLocale, { path: '/' });
    return res;
  }

  // --- 公開ファイルはスキップ ---
  if (PUBLIC_FILE.test(pathname)) return;

  // --- パブリックリンクの場合、ロケールプレフィックスがなければ追加 ---
  if (isPublicPlanPageURL(pathname) && !routing.locales.includes(locale)) {
    const cookieLocale = req.cookies.get('locale')?.value as Locale | undefined;
    const matchedLocale: Locale =
      cookieLocale && routing.locales.includes(cookieLocale) ? cookieLocale : routing.defaultLocale;

    const url = req.nextUrl.clone();
    url.pathname = `/${matchedLocale}${pathname}`;
    return NextResponse.redirect(url);
  }

  const session = await auth();
  const isPublicRoute = PUBLIC_ROUTES.some((route) => pathname.includes(route));

  // --- 認証済みが認証ページにアクセス → トップにリダイレクト ---
  if (session && isPublicRoute) {
    return NextResponse.redirect(new URL(getHomePagePath(locale), req.url));
  }

  // --- 未認証が公開ページ以外にアクセス → ログインページにリダイレクト ---
  if (!session && !isPublicRoute && !isPublicPlanPageURL(pathname)) {
    return NextResponse.redirect(new URL(getLoginPagePath(locale), req.url));
  }

  // --- next-intl ミドルウェア適用 ---
  const intlResponse = intlMiddleware(req);
  intlResponse.headers.set('x-url', pathname);
  intlResponse.headers.set('x-locale', locale);

  // 認証済みなら Cookie にパスを残す
  if (session) {
    intlResponse.cookies.set('X_URL', pathname, { path: '/' });
  }

  return intlResponse;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
};
/*
、正規表現でUUIDを抽出するところだけ、汎用性を上げるためにLOCALE TYPEを書いておく。
*/
export type Locale = 'ja' | 'en'; // 必要に応じて他言語追加

const LOCALES: Locale[] = ['ja', 'en'];
const localePattern = LOCALES.join('|');
export const UUID_PATH_REGEX = new RegExp(
  `^/(?:(${localePattern})/)(?:public/)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
);


export const PUBLIC_UUID_PATH_REGEX = new RegExp(`^/(?:(${localePattern})/)public/`);
/**
 * 現在のページがパブリックリンクのページか判定する
 */
export const isPublicPlanPageURL = (path: string) => {
  const localePattern = LOCALES.join('|');
  return new RegExp(`^/(?:(${localePattern})/)?plan/public/`).test(path);
};

/**
 * 現在のページがパブリックリンクのページか判定する
 */
export const isPublicPlanPageURL = (path: string) => {
  return PUBLIC_UUID_PATH_REGEX.test(path);
};

/**
 * 未認証ユーザー用のログインページパスを生成
 */
export const getLoginPagePath = (locale: Locale) => `/${locale}/auth/login`;

/**
 * 認証済みユーザー用のトップページパス
 */
export const getHomePagePath = (locale: Locale) => `/${locale}/`;
import '../globals.css';
import { ReactNode } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { headers } from 'next/headers';
import PageTemplate from '@/components/templates/PageTemplate';
import NoExistUserSessionLogout from '@/components/atoms/NoExistUserSessionLogout';
import prisma from '@/utils/prismaClient';
import { auth } from '@/auth';
import { makeImagePath } from '@/utils/image';
import { TRPCProvider } from '@/trpc/client';
import { extractPlanUUIDFromPath, isPublicPlanPageURL } from '@/utils/path';
import { makeGuestUser } from '@/utils/user';
import getRequestConfig from '@/i18n/request';
import { routing } from '@/i18n/routing';

export default async function RootLayout({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  const reqHeaders = await headers();
  const pathname = reqHeaders.get('x-url');
  // --- next-intl 用 locale / messages を取得 ---
  const locale = reqHeaders.get('x-locale') || routing.defaultLocale; // middleware でヘッダーにセットしていればここから取得
  const requestConfig = await getRequestConfig({ requestLocale: Promise.resolve(locale) });

  // --- loginしなくても見れる情報の取得をここでやる。公開プラン判定 ---
  const isPublicPage = pathname ? isPublicPageURL(pathname) : false;
  const uuid = pathname ? extractUUIDFromPath(pathname) : null;
  const session = await auth();
  const user ; ログインユーザーの取得をsession情報から照らし合わせて行う。ログインしていないユーザーの場合は、ここで閲覧者用の使い捨てユーザーを用意する。

  // --- レンダリング ---
  return (
    <html>
      <body className="min-h-screen">
        <TRPCProvider>
          <NextIntlClientProvider locale={requestConfig.locale} messages={requestConfig.messages}>
            <NoExistUserSessionLogout hasSession={!!session} hasUser={!!user}>
              {user ? (
                <PageTemplate>
                  {children}
                </PageTemplate>
              ) : (
                <>{children}</> // ログインページなどの認証が不要なページ
              )}
            </NoExistUserSessionLogout>
          </NextIntlClientProvider>
        </TRPCProvider>
      </body>
    </html>
  );
}

↑これをsrc/app/[locale]/layout.tsxにおく。

import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  // リクエストされた言語の取得と検証
  const requested = await requestLocale;
  // サポートされている言語かチェックし、未対応の場合はデフォルト言語を使用
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

  return {
    locale,
    messages: (await import(`@/../messages/${locale}.json`)).default,
  };
});
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';

// 利用可能な言語とデフォルト言語を設定
export const routing = defineRouting({
  locales: ['en', 'ja'],
  defaultLocale: 'ja',
});

// ナビゲーション用のユーティリティを作成
export const { Link, useRouter } = createNavigation(routing);

↑2つをsrc/i18n直下に、それぞれrequest.ts, routing.tsとして置く。

そして、next.config.tsを

import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

// 翻訳設定ファイルのパスを指定してプラグイン作成
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    remotePatterns: [{ hostname: 'lh3.googleusercontent.com', protocol: 'https' }],
  },
  serverExternalPackages: ['pino', 'pino-pretty'],
  output: 'standalone',
  experimental: {
    authInterrupts: true,
  },
};

// Next.js 設定を next-intl でラップして export
export default withNextIntl(nextConfig);
 { "common": {
    "//": "汎用的に使う翻訳 ",
    "search": "Search",
    "clear": "Clear",
    "copy": "Copy",
    "editAttribute": "Edit Attribute",
    "delete": "Delete",
    "toggleArchive": "Toggle Archive",
    "archive": "Archive",
    "description": {
      "//": "説明コンポーネントに使う翻訳 ",
      "editLabel": "Edit description",
      "viewLabel": "View description",
      "placeholder": "Enter a description",
      "submit": "Update"
    }
  },
  "header": {
    "publicLinkAccess": "Public link access",
    "menu": {
      "adminPanel": "Admin Panel",
      "profileSettings": "Profile Settings",
      "logout": "Logout"
    }
}

みたいに、root/messages/ja, en.jsonを書く。