🔑

Next.js 15(App Router) x Auth0のクライアントサイドで起きる現象と対応

に公開

はじめに

Next.js(App Router)のプロジェクトにAuth0の認証を組み込むにあたって、公式からNext.js用にnextjs-auth0というSDKが用意されています。nextjs-auth0を利用するにあたって気になる点があったので状況のメモと、取った対応について書いてみようと思います。

前提

執筆時の各ライブラリのバージョンは以下になります。また、SDKを動作させる上での基本的な設定等は割愛します。

  • Next.js 15.5.2
  • nextjs-auth0 4.9.0

クライアントサイドでユーザー情報を取得する

Next.js App Routerは、基本的にサーバーサイドで処理が行われ、ユーザーからのアクションをトリガーにして何かを行う場合に 'use client' を宣言しクライアントサイドでの処理に切り替えましょうという設計になっています。

nextjs-auth0もその前提に則って実装されており、基本的な認証は当然全てサーバーサイドで行われ、クライアントサイドで必要な場合の機能やフックが部分的に用意されている形です。

例えばクライアントサイドのUIでログイン済みかどうかを確認したり、ユーザーが自分の情報を編集するフォームがあった場合、以下のようにクライアントコンポーネント内で useUser(); というフックを使用することになっています。

"use client";

import { useUser } from "@auth0/nextjs-auth0";
import { User } from "@auth0/nextjs-auth0/types";

export const EditProfile = () => {
  const { user } = useUser();

  if (!user) {
    // 非ログインユーザーにはメッセージが表示される
    return <p>Please Login.</p>;
  }
  return EditProfileUI(user);
};

const EditProfileUI = (user: User) => {
  // userの情報を使って何かするコンポーネント
  return (
    <div>
      <form>
        <input type="email" placeholder="Email" />
        <button type="submit">Save</button>
      </form>
    </div>
  );
};

非ログイン時の振る舞い

useUser() フックを使って実装していた際、ブラウザのコンソールにエラーが出ている事に気付きました。

ブラウザコンソールに表示されたエラーメッセージのスクリーンショット

身に覚えが無い上にどんどん増えていきます。そして自分で実装していない /auth/profile というパスにリクエストが飛んでいるようです。

nextjs-auth0は、有効化されるとmiddlewareを使って複数のAPI Routeを展開してくれます。例えばログイン・ログアウトに使用する /auth/login/auth/logout といったパスが有効になり、とてもシンプルにAuth0との連携が可能になります。

/auth/profile もその一つです。このURLにリクエストを行うと、ログインユーザーにはユーザーの情報が返り、非ログインユーザーには401 Unauthorizedが返るようになっています。
useUser() は、クライアントサイドからこのエンドポイントを叩くようになっているのがエラーの理由だと分かります。

useUser() の実装を見てみると /auth/profile へのリクエストにswrを使っており、fetchが成功してもしなくても色々なタイミングで再検証が行われるため、非ログイン時に定期的にコンソールエラーが積み上がって行くのも実装通りと言えるでしょう。

https://github.com/auth0/nextjs-auth0/blob/main/src/client/hooks/use-user.ts

どうする?

一般的なユーザーはウェブサイトを閲覧している時に開発者ツールを開いたりしないですし、これによって何か表面上操作ができなくなると言った問題は発生しないのでこのままで良いかとも思ったのですが、ローカルの開発環境ではなくサーバーにデプロイした上でBasic認証を設定した場合に、 /auth 以下へのリクエストが最初に通過した認証と独立したリクエストとして処理されることで何度もBasic認証を求められるといった状況が発生しました。
この事象に何らか個別に対応する事はできたかもしれませんが、コンソールエラーが積み上がる状況へのもどかしさもあり根本的な解決ができないか検討した結果、以下のアプローチを取ることにしました。

対応

サーバーサイドで取得したユーザー情報を、Contextを利用してクライアントコンポーネントに提供することにしました。例えば、全体で共通のRootLayoutでユーザー情報を取得しようとすると以下のようになります。

layout.tsx
import { Auth0Client } from "@auth0/nextjs-auth0/server";
import { User } from "@auth0/nextjs-auth0/types";

const auth0 = new Auth0Client();

export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode; }>) {
  const session = await auth0.getSession();
  const user: User | undefined = session?.user;

  return (
    <html lang="en"><body>{children}</body></html>
  );
}

ここで取得できるUser型はクライアントサイドで使用する useUser() で取得できるのと同じ型なので、基本的にはクライアントサイドに渡しても問題無いデータではあるものの状況に応じて必要な情報だけに絞るといった処理も実装した方が良いかもしれません。

次に、サーバーサイドで取得したデータをクライアントサイドで使う準備をします。

情報を保持するContextと、それを配信するProvider、Auth0の useUser() に相当する、クライアントコンポーネントでContextからユーザー情報を取得する useAppUser() 関数を用意して行きます。

UserProvider.tsx
"use client";

import React, { createContext, useContext } from "react";
import { User } from "@auth0/nextjs-auth0/types";

const Ctx = createContext<User | undefined>(undefined);

export function UserProvider({
  initialUser,
  children,
}: {
  initialUser: User | undefined;
  children: React.ReactNode;
}) {
  return <Ctx.Provider value={initialUser}>{children}</Ctx.Provider>;
}

export function useAppUser(): User | undefined {
  return useContext(Ctx);
}

作成したProviderを、RootLayoutに組み込んで行けば準備完了です。

layout.tsx
export default async function RootLayout({ children }: Readonly<{ children:React.ReactNode; }>) {
  const session = await auth0.getSession();
  const user: User | undefined = session?.user;

  return (
    <html lang="en">
      <body>
        <UserProvider initialUser={user}>
          {children}
        </UserProvider>
      </body>
    </html>
  );
}

冒頭で提示したEditProfileコンポーネントは、以下のように書き換えるだけで同様の処理になります。

+ import { useAppUser } from "@/contexts/UserProvider";
- import { useUser } from "@auth0/nextjs-auth0";
import { User } from "@auth0/nextjs-auth0/types";

export const EditProfile = () => {
+   const user = useAppUser();
-   const { user } = useUser();

  if (!user) {
    // 非ログインユーザーにはメッセージが表示される
    return <p>Please Login.</p>;
  }
  return EditProfileUI(user);
};

まとめ

分かりやすくするために書く処理については必要最低限に収めていますが、要点としてはサーバーサイドで取得したユーザー情報をContextを使ってクライアント側に配信するという基本的な内容になっています。
今回は、自動的に再検証が行われる前提の useUser() 関数を使わないアプローチを取りましたが、ログイン/ログアウトがよりシームレスに行われるサービス等、画面遷移を伴わずにユーザーの状態が変わる可能性が高い状況では useUser() を使った方が良いケースもあるかと思いますので柔軟に判断していただければと思います。
また、SDKで用意されていない方法でサーバー-クライアント間のデータの取り回しを行う際は自己責任となりますので、トークン等の重要な情報が誤ってクライアントサイドに渡ってしまわないよう注意しましょう。

記事の内容について、間違っている点などあればご指摘いただければと思います。

Discussion