🔐

【第3回】NestJS(GraphQL)×Next.jsでJWT認証を実装する - Next.jsでの認証管理とルート保護(全3回)

に公開

シリーズの振り返りと今回のテーマ

前回までの記事では、NestJSバックエンドでのJWT認証の実装とロールベースアクセス制御について解説しました。第1回ではPassportやJwtStrategyを使った認証基盤の構築、第2回ではGuardとデコレーターを活用した認可機能について詳しく説明してきました!

https://zenn.dev/stellarcreate/articles/nestjs-graphql-nextjs-jwt-auth-part1

https://zenn.dev/stellarcreate/articles/nestjs-graphql-nextjs-jwt-auth-part2

シリーズ最終回となる今回は、Next.jsフロントエンドでの認証実装に焦点を当てます。Apollo Clientでの認証ヘッダー管理から、Context APIを使った認証状態の管理、ルート保護の実装まで、実際に実装したコードをベースに解説していきます!

今回のコードは下記に公開しています。

https://github.com/toto-inu/202511-ts-auth

今回実装する機能の全体像

今回のフロントエンド実装では、以下の機能を構築しました:

  • Apollo Client でのJWTトークン自動付与
  • Context API による認証状態の全体管理
  • ProtectedRoute コンポーネントによるルート保護
  • Navbar でのユーザー情報表示とロール別メニュー

これらの機能により、ユーザーはログイン後に適切な権限で各ページにアクセスできるようになります。

プロジェクト構成とファイル配置

実装したファイル構成は以下のとおりです:

front/
├── app/
│   ├── layout.tsx                    # プロバイダーの設定
│   └── ...
├── lib/
│   ├── apollo-client.ts             # Apollo Client設定
│   ├── apollo-provider.tsx          # Apollo Providerラップ
│   ├── auth-context.tsx             # 認証状態管理
│   └── types.ts                     # 型定義
└── components/
    ├── auth/
    │   └── protected-route.tsx      # ルート保護
    └── layout/
        └── navbar.tsx               # ナビゲーションバー

Apollo Clientのセットアップ

apollo-client.tsの実装

GraphQLリクエストに自動的にJWTトークンを含めるため、Apollo Clientの setContext を使用しています。

// front/lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:4001/graphql',
});

const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

export const apolloClient = new ApolloClient({
  link: from([authLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'network-only',
      errorPolicy: 'all',
    },
    mutate: {
      errorPolicy: 'all',
    },
  },
});

重要なポイント:

  • typeof window !== 'undefined' でSSR時のwindowオブジェクト不存在を回避
  • from([authLink, httpLink]) でリンクの実行順序を制御
  • errorPolicy: 'all' でエラーとデータの両方を受け取り可能

ApolloProviderのラップ

Apollo Clientをアプリケーション全体で使用するため、シンプルなwrapperコンポーネントを作成しました。

// front/lib/apollo-provider.tsx
'use client';

import { ApolloProvider } from '@apollo/client/react';
import { apolloClient } from './apollo-client';

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>;
}

認証状態の管理

AuthContextの実装

認証状態を一元管理するため、Context APIを使用して実装しました。

// front/lib/auth-context.tsx
'use client';

import React, { createContext, useContext, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLazyQuery } from '@apollo/client/react';
import { GET_ME } from '@/graphql/queries/auth';
import { User } from './types';

interface AuthContextType {
  user: User | null;
  loading: boolean;
  login: (token: string, user: User) => void;
  logout: () => void;
  refetchUser: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const router = useRouter();

  const [getMe, { data, error }] = useLazyQuery(GET_ME, {
    fetchPolicy: 'network-only',
  });

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      getMe();
    } else {
      setLoading(false);
    }
  }, [getMe]);

  useEffect(() => {
    if (data?.me) {
      setUser(data.me);
      setLoading(false);
    } else if (error) {
      localStorage.removeItem('token');
      setUser(null);
      setLoading(false);
    }
  }, [data, error]);

  const login = (token: string, userData: User) => {
    localStorage.setItem('token', token);
    setUser(userData);
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
    router.push('/login');
  };

  const refetchUser = () => {
    const token = localStorage.getItem('token');
    if (token) {
      getMe();
    }
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout, refetchUser }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

実装のポイント:

  • 初期化処理: アプリ起動時に localStorage のトークンをチェックし、存在する場合のみ getMe を実行
  • エラーハンドリング: トークンが無効な場合は自動的に localStorage から削除
  • 状態管理: loading 状態でユーザー体験を向上
  • 関数提供: login, logout, refetchUser で外部から認証状態を操作可能

LayoutでのProvider統合

Next.jsの layout.tsx で、プロバイダーを適切にネストして配置しました。

// front/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ApolloWrapper } from "@/lib/apollo-provider";
import { AuthProvider } from "@/lib/auth-context";
import { Toaster } from "sonner";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Todo App - JWT Auth & RBAC",
  description: "Full-stack Todo application with authentication and role-based access control",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <ApolloWrapper>
          <AuthProvider>
            {children}
            <Toaster richColors position="top-right" />
          </AuthProvider>
        </ApolloWrapper>
      </body>
    </html>
  );
}

ネスト順序の重要性: ApolloWrapperAuthProviderchildren の順序で、Apollo ClientがAuthContextより外側に配置されています。

ルート保護の実装

ProtectedRouteコンポーネント

認証が必要なページをラップするための ProtectedRoute コンポーネントを実装しました。

// front/components/auth/protected-route.tsx
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Role } from '@/lib/types';

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: Role;
}

export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { user, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!loading) {
      if (!user) {
        router.push('/login');
      } else if (requiredRole && user.role !== requiredRole) {
        router.push('/dashboard');
      }
    }
  }, [user, loading, requiredRole, router]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-center">
          <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
          <p className="mt-4 text-muted-foreground">Loading...</p>
        </div>
      </div>
    );
  }

  if (!user || (requiredRole && user.role !== requiredRole)) {
    return null;
  }

  return <>{children}</>;
}

機能説明:

  • requiredRole プロップ: ロールベースアクセス制御(RBAC)に対応
  • リダイレクト処理: 未認証なら /login へ、権限不足なら /dashboard
  • ローディング表示: 認証状態確認中はスピナーを表示
  • 条件分岐: 認証・認可チェック後に内容を表示

使用例

// 通常のページ保護
<ProtectedRoute>
  <DashboardContent />
</ProtectedRoute>

// 管理者のみアクセス可能
<ProtectedRoute requiredRole={Role.ADMIN}>
  <AdminPanel />
</ProtectedRoute>

ユーザーインターフェースの実装

認証状態とロールに応じたナビゲーションバーを実装しました。

// front/components/layout/navbar.tsx
'use client';

import { useAuth } from '@/lib/auth-context';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import { Role } from '@/lib/types';

export function Navbar() {
  const { user, logout } = useAuth();

  if (!user) return null;

  return (
    <nav className="border-b bg-white dark:bg-gray-950">
      <div className="container mx-auto px-4 py-3 flex items-center justify-between">
        <div className="flex items-center space-x-6">
          <Link href="/dashboard" className="text-xl font-bold">
            Todoアプリ
          </Link>
          <div className="flex space-x-4">
            <Link href="/dashboard">
              <Button variant="ghost">ダッシュボード</Button>
            </Link>
            {user.role === Role.ADMIN && (
              <Link href="/categories">
                <Button variant="ghost">カテゴリ</Button>
              </Link>
            )}
          </div>
        </div>
        <div className="flex items-center space-x-4">
          <div className="flex items-center space-x-2">
            <span className="text-sm text-muted-foreground">{user.email}</span>
            <Badge variant={user.role === Role.ADMIN ? 'default' : 'secondary'}>
              {user.role === Role.ADMIN ? '管理者' : 'ユーザー'}
            </Badge>
          </div>
          <Button onClick={logout} variant="outline">
            ログアウト
          </Button>
        </div>
      </div>
    </nav>
  );
}

実装の特徴:

  • 条件表示: ログインしていない場合はNavbarを表示しない
  • ロールベース: ADMIN ロールのみ「カテゴリ」メニューを表示
  • ユーザー情報: メールアドレスとロールをBadgeで表示
  • ログアウト機能: ボタンクリックで logout() を実行

App Routerでの実装ポイント

'use client'ディレクティブの使用

今回の認証関連コンポーネントはすべて 'use client' ディレクティブを使用しています。

なぜクライアントコンポーネントが必要なのか:

機能 理由
localStorage ブラウザAPIのため、サーバーサイドでは利用不可
useState, useEffect Reactフックはクライアントコンポーネントでのみ使用可能
useRouter Next.jsのクライアント側ルーティング機能
useContext Context APIはクライアント側の状態管理

layoutでのProvider構成

html
└── body
    └── ApolloWrapper
        └── AuthProvider
            └── children (各ページ)
                └── Toaster

この構成により、アプリケーション全体でApollo ClientとAuthContextが利用可能になります。

認証フローの動作確認

実装した認証システムの動作フローは以下のとおりです:

1. アプリ起動時の初期化

  • AuthProvider がマウント
  • localStorage からトークンを確認
  • トークンが存在する場合、getMe クエリを実行
  • ユーザー情報を取得して状態を更新

2. ログイン処理

  • ユーザーがログインフォームを送信
  • GraphQL Mutation でトークンを取得
  • login() 関数でトークンを localStorage に保存
  • ユーザー情報を Context に設定

3. 認証済みリクエスト

  • Apollo Client の authLink が自動実行
  • localStorage からトークンを取得
  • Authorization: Bearer <token> ヘッダーを追加
  • GraphQL リクエストを送信

4. ルート保護の動作

  • ProtectedRoute コンポーネントがマウント
  • useAuth() で認証状態を確認
  • 条件に応じてリダイレクトまたは内容を表示

5. ログアウト処理

  • logout() 関数が実行
  • localStorage からトークンを削除
  • Context の user を null に設定
  • /login ページにリダイレクト

型定義と型安全性

実装では TypeScript による型安全性を重視しています。

// 使用している主要な型
export enum Role {
  USER = 'USER',
  ADMIN = 'ADMIN',
}

export interface User {
  id: number;
  email: string;
  role: Role;
  createdAt: string;
  updatedAt: string;
}

interface AuthContextType {
  user: User | null;
  loading: boolean;
  login: (token: string, user: User) => void;
  logout: () => void;
  refetchUser: () => void;
}

これにより、コンパイル時に型チェックが行われ、ランタイムエラーを防ぐことができます。

今回の実装で意識したポイント

1. シンプルな構成

  • localStorage を使用したトークン管理で実装をシンプルに
  • Context API による認証状態の一元管理
  • 必要最小限の機能に絞って実装

2. ユーザー体験の向上

  • ローディング状態の適切な表示
  • エラー時の自動的なトークン削除
  • 権限に応じた適切なリダイレクト

3. 責務の分離

  • Apollo Client: GraphQL通信とヘッダー管理
  • AuthContext: 認証状態の管理
  • ProtectedRoute: アクセス制御
  • Navbar: UI表示

4. TypeScript活用

  • 型定義による開発時の安全性向上
  • インターフェースによる明確な契約定義

今後の改善案

今回の実装は最低限のシンプルさを重視しましたが、本格運用では以下の点も検討した方が良いです。

  • セキュリティ強化: httpOnly Cookieによるトークン管理
  • サーバーサイド認証: Next.js Middlewareを活用した認証チェック
  • トークン管理: リフレッシュトークンによる長期セッション対応
  • エラーハンドリング: Apollo Client の onError リンクによる統一的なエラー処理

まとめ

3回にわたるシリーズを通じて、NestJSとNext.jsを組み合わせたJWT認証システムの実装を解説しました。最終回の今回は、Next.jsでの認証管理とルート保護について、実際のコードをベースに詳しく説明しました!

実装したフロントエンド機能:

  • Apollo Client による認証ヘッダーの自動管理
  • Context API を使った認証状態の一元管理
  • ProtectedRoute によるロールベースのアクセス制御
  • Navbar でのユーザー情報表示とメニュー制御

これらの実装により、バックエンドのJWT認証システムと連携した、完全なフルスタック認証アプリケーションが完成しました。シンプルな構成ながら、実用的なレベルでの認証・認可機能を提供できています。

このシリーズが、皆さんの開発プロジェクトで認証機能を実装する際の参考になれば幸いです!✨

株式会社StellarCreate | Tech blog📚

Discussion