プログラミング

【Next.js】多言語対応時のNextAuth設定

概要

以前書いた以下記事でNext.jsにおける多言語(i18n)対応について説明しました。

本記事では上記の多言語対応をしつつNextAuthの設定を行う為の実装を簡単に書き溜めた記事となります。本記事を取り込む前に上記記事をまずは確認&実装してみることをお勧めします。
これにより、多言語対応×NextAuthの設定で悩んでる方の助けになればと思います。

NextAuthとは

まず「NextAuthとは」ですが、Next.jsにおける認証(ログインとログアウト)の仕組みを簡単に実装できるライブラリとなります。以下NextAuthの公式サイトなります。

NextAuth公式サイト

上記使用することで認証処理を1からの実装が不要になるので、開発者はアプリ開発によりフォーカスすることができます。また多くの認証プロバイダー(Google、Facebook、GitHub、Twitterなど)に対応しているので、こんな認証方法をアプリに実装したいな!がより簡単に実現が可能になります。

NextAuth組み込み

まずは開発プロジェクトのディレクトリにて以下コマンドを実行し、NextAutnをインストールしましょう(npm使っている方はnppm installです!)

yarn add next-auth

インストール後、src/lib/auth.tsに以下コードを貼り付けてください。
本来は以下authorize関数やsignOut関数にバックエンドのログインAPI、サインアウトAPIを実行する処理を入れますが、今回は多言語対応との組み合わせの解説により、バックエンドなしでフロントのみの実装に留めますので、かなり簡単な実装になっております。

import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

export const authOptions: NextAuthOptions = {
  pages: {
    signIn: "/",
  },
  session: {
    strategy: "jwt",
    maxAge: 24 * 60 * 60, // 1日 (秒単位)
  },
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: {
        username: {
          label: "Username",
          type: "text",
        },
        password: {
          label: "Password",
          type: "password",
        },
      },
      async authorize(credentials) {
        return { id: `${credentials?.username}:${credentials?.password}` };
      },
    }),
  ],
};

次にsrc/app/api/auth/[...nextauth]フォルダを作成し、以下route.tsを作成してください。

import { authOptions } from '@/lib/auth';
import NextAuth from 'next-auth';

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

これでNextAuthの設定は完了です。

多言語対応と組み合わせよう!

上記で共有した多言語対応と組み合わせる際に、何が大変かとそれぞれNext.jsのミドルウェアの設定をしなければいけません。
各設定したミドルウェアを数珠繋ぎで実行する為のコードを書く必要があります。src/middlewaresフォルダを作成し、以下chain.tsファイルを作成してください。

import { NextMiddlewareResult } from "next/dist/server/web/types";
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";

export type CustomMiddleware = (
  request: NextRequest,
  event: NextFetchEvent,
  response: NextResponse
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;

type MiddlewareFactory = (middleware: CustomMiddleware) => CustomMiddleware;
export function chain(
  functions: MiddlewareFactory[],
  index = 0
): CustomMiddleware {
  const current = functions[index];
  if (current) {
    const next = chain(functions, index + 1);
    return current(next);
  }

  return (
    request: NextRequest,
    event: NextFetchEvent,
    response: NextResponse
  ) => {
    return response;
  };
}

次にAuthNextの設定です。src/middlewaresに以下withAuthMiddleware.tsを作成してください。

import { i18nConfig } from "@/i18n/config";
import { getToken } from "next-auth/jwt";
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CustomMiddleware } from "./chain";
import { getLocale, isMissingLocale } from "./withI18nMiddleware";

const authRoutes = ["/home"];
const guestRoutes = ["/"];
export function withAuthMiddleware(
  middleware: CustomMiddleware
): CustomMiddleware {
  return async (
    request: NextRequest,
    event: NextFetchEvent,
    response: NextResponse
  ) => {
    const token = await getToken({
      req: request,
      secret: process.env.NEXTAUTH_SECRET,
    });

    const pathname = request.nextUrl.pathname;
    const isIndexPage = pathname === getPathWithLocale(request, "/");
    const isAuthRoute = authRoutes.some((route) =>
      pathname.startsWith(getPathWithLocale(request, route))
    );
    const isGuestRoute = guestRoutes.some(
      (route) => pathname === getPathWithLocale(request, route)
    );

    if (!token && isAuthRoute) {
      const redirectUrl = new URL(getPathWithLocale(request, "/"), request.url);
      redirectUrl.searchParams.set("callbackUrl", request.nextUrl.href);
      return NextResponse.redirect(redirectUrl);
    }

    if (token) {
      if (isIndexPage || isGuestRoute) {
        return NextResponse.redirect(
          new URL(getPathWithLocale(request, "/home"), request.url)
        );
      }
    }

    return middleware(request, event, response);
  };
}

export function getPathWithLocale(request: NextRequest, path: string): string {
  const pathname = request.nextUrl.pathname;
  const locale = getLocale(request);
  if (isMissingLocale(pathname) && locale !== i18nConfig.defaultLocale) {
    return `/${locale}${path}`;
  }
  return path;
}

次に多言語対応の設定です。src/middlewaresに以下withI18nMiddleware.tsを作成してください。

import { i18nConfig } from "@/i18n/config";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
import { i18nRouter } from "next-i18n-router";
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CustomMiddleware } from "./chain";

export function withI18nMiddleware(middleware: CustomMiddleware) {
  return async (
    request: NextRequest,
    event: NextFetchEvent,
    response: NextResponse
  ) => {
    const pathname = request.nextUrl.pathname;
    const locale = getLocale(request);
    if (isMissingLocale(pathname) && locale !== i18nConfig.defaultLocale) {
      const redirectURL = new URL(request.url);
      if (locale) {
        redirectURL.pathname = `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`;
      }

      // クエリパラメータの保存
      redirectURL.search = request.nextUrl.search;

      return NextResponse.redirect(redirectURL.toString());
    }

    return middleware(request, event, i18nRouter(request, i18nConfig));
  };
}

export function isMissingLocale(pathname: string): boolean {
  return i18nConfig.locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  );
}

export function getLocale(request: NextRequest): string | undefined {
  if (isMissingLocale(request.nextUrl.pathname)) {
    return i18nConfig.defaultLocale;
  } else {
    const negotiatorHeaders: Record<string, string> = {};
    request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

    const locales: string[] = i18nConfig.locales;
    const languages = new Negotiator({
      headers: negotiatorHeaders,
    }).languages();

    const locale = matchLocale(languages, locales, i18nConfig.defaultLocale);
    return locale;
  }
}

src/middleware.tsを作成し、以下実装します。

import { chain } from "./middlewares/chain";
import { withAuthMiddleware } from "./middlewares/withAuthMiddleware";
import { withI18nMiddleware } from "./middlewares/withI18nMiddleware";

export default chain([withI18nMiddleware, withAuthMiddleware]);

// only applies this middleware to files in the app directory
export const config = {
  matcher: "/((?!api|static|.*\\..*|_next).*)",
};

src/[lang]/page.tsxを作成し、以下実装します。

"use client";

import { initI18n } from "@/i18n/config";
import { t } from "i18next";
import { signIn } from "next-auth/react";

const i18n = initI18n();
export default function Page({
  params: { lang },
}: {
  params: {
    lang: string;
  };
}) {
  // 多言語設定
  i18n.changeLanguage(lang);
  const hello = t("hello");
  // ログイン処理
  const clickHandler = async () => {
    await signIn("credentials", {
      username: "",
      password: "",
      callbackUrl: "/home",
      redirect: true,
    });
  };
  return (
    <body>
      <h1>{hello}</h1>
      <button onClick={clickHandler}>ログイン</button>
    </body>
  );
}

src/[lang]/home/page.tsxを作成し、以下実装します。

"use client";

import { signOut } from "next-auth/react";

export default function Page() {
  // ログアウト処理
  const clickHandler = async () => {
    signOut({ callbackUrl: "/" });
  };
  return (
    <body>
      <h1>ホーム</h1>
      <button onClick={clickHandler}>ログアウト</button>
    </body>
  );
}

最後に.env.localに以下設定を行なって準備完了です!

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<「openssl rand -base64 32」を実行して生成したランダムな文字列>

動かしてみよう!

まず、localhost:3000/homeに遷移してみてください。ログインしてない為、以下のようにトップ画面にリダイレクトされるはずです。(前回の多言語対応時は勝手にブラウザの言語設定で日本語がデフォルトになってますが、今回は英語がデフォルトになってます)
そこでトップ画面でログインボタンを押下すれば、home画面に遷移します!

またURLをlocalhost:3000/jaに遷移すれば日本語のログイン画面に遷移します!(英語に戻したい場合、/enと入力してください)

最後に

これで多言語設定とNextAuthを掛け合わせたアプリを作ることができましたね!この部分で停滞していた方の助けになればと思います!
また、本実装ではバックエンドの実装を吹っ飛ばしております(メインは多言語設定×NextAuthの為)。実際にアプリに組み込む際はバックエンドとの通信を行い、保存したい値をsessionに持たせるような実装が必要です!

スポンサーリンク

  • この記事を書いた人

かず

バックエンド/フロントエンド/クラウドインフラのフルスタックエンジニア | デジタルノマド(‘23/6〜) で世界放浪中 | 主にプログラミングに役立つ情報を発信 | Xでは旅の様子を発信

-プログラミング
-,