0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js基礎コース App Router やってみた 13 ~ 15

Last updated at Posted at 2026-01-31

Next.jsの勉強がてら公式のチュートリアルを1からなぞってみました。

実際にチュートリアルをベースに書いたソースコードはこちら

13. エラー処理

try/catchサーバーアクションへの追加

まずはアクション内でキャッチされていない例外が発生した場合にどうなるかを見てみましょう。

/app/lib/actions.ts

export async function deleteInvoice(id: string) {
  throw new Error('Failed to Delete Invoice');  // わざと例外を発生させる

  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

請求書を削除しようとすると、ローカルホストで以下のようなエラーが表示されページ自体が全く表示されなくなります。

 ⨯ Error: Failed to Delete Invoice
    at deleteInvoice (app/lib/actions.ts:81:9)
  79 |
  80 | export async function deleteInvoice(id: string) {
> 81 |   throw new Error('Failed to Delete Invoice');  // わざと例外を発生させる
     |         ^
  82 |
  83 |   await sql`DELETE FROM invoices WHERE id = ${id}`;
  84 |   revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得 {
  digest: '770613014'
}
 POST /dashboard/invoices 500 in 173ms
13_error.png (49.8 kB)

エラーを適切に処理できるように、 try/catch をサーバーアクションに追加してメッセージエラーメッセージを返したくなるところですが、 <form action={...}> に設定される関数の戻り値の型は Promise<void> が期待されているため、ビルドで失敗します。
※ ちなみに、、、 try/catch を利用する場合は redirecttry 節に入れないように注意してください。redirect はエラーをスローすることで動作するため、エラーがキャッチされると動作しなくなります。

/app/lib/actions.ts

export async function createInvoice(formData: FormData) {
  // ...

  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${data})
    `;
  } catch (error) {
    console.error(error);
    return {
      message: 'Database Error: Failed to Create Invoice.',
    }
  }

  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
  redirect('/dashboard/invoices');  // 請求書一覧ページにリダイレクト
}

error.tsx ですべてのエラーを処理する

error.tsxファイルは、ルートセグメントのUI境界を定義するために使用できます。予期しないエラーの受け皿として機能し、ユーザーにフォールバックUIを表示することを可能にします。

エラーハンドリング関数( Error )は2つのプロパティを受け入れます。

  • error : JavaScriptのネイティブインスタンス Error です。
  • reset : エラー境界をリセットする関数です。実行されると、ルートセグメントの再レンダリングを試みます。

/app/dashboard/invoices/error.tsx

'use client';  // error.tsx はクライアントコンポーネントである必要があります。

import { useEffect } from "react";

export default function Error({ error, reset }: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // オプションでエラーをエラー報告サービスに記録する
    console.error(error);
  }, [error]);

  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={
          // 請求書ルートを再レンダリングして回復を試みる
          () => reset()
        }
      >
        Try again
      </button>
    </main>
  )
}
13_handling_all_errors.png (174.2 kB)

Error & { digest?: string } 型について

Error & { digest?: string } は TypeScript の交差型(intersection type)です。
Error 型が持つプロパティ(name, message, stack など)に加えて、digest という省略可能(?がついている)な文字列プロパティを持つオブジェクトであることを表します。
Next.js のエラーバウンダリでは内部的にエラーに digest(内部で使う識別用ハッシュ)を付与することがあるため、その情報が渡ってくる可能性を考慮してこの型になっています。

請求書の作成・編集画面のSQLのエラーハンドリング

捕捉されていない例外を表示できる画面ができたので、請求書の作成・編集処理のSQLを try/catch で囲んでエラーハンドリングを行います。

/app/lib/actions.ts

export async function createInvoice(formData: FormData) {
  // ...
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${data})
    `;
  } catch (error) {
    console.error(error);
    throw new Error('Database Error: Failed to Create Invoice.');
  }

  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
  redirect('/dashboard/invoices');  // 請求書一覧ページにリダイレクト
}


export async function updateInvoice(id: string, formData: FormData) {
  // ...
  try {
    await sql`
      UPDATE invoices
      SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
      WHERE id = ${id}
    `;
  } catch (error) {
    console.error(error);
    throw new Error('Database Error: Failed to Update Invoice.');
  }

  revalidatePath('/dashboard/invoices');  // キャッシュをクリアして、請求書一覧ページを再検証・データを再取得
  redirect('/dashboard/invoices');  // 請求書一覧ページにリダイレクト
}

notFound 関数で404エラーを処理する

エラーを適切に処理するもう一つの方法は、 notFound 関数を使用することです。
error.tsx はキャッチされていない例外をキャッチするのに便利ですが、notFound は存在しないリソースを取得しようとする場合にも使用できます。

指定された id で請求書を取得できなかった場合に notFound 関数を呼び出す呼び出す条件を追加します。

/app/dashboard/invoices/[id]/edit/page.tsx

// ...
import { notFound } from "next/navigation";  // 追加

export default async function Page(props: {params: Promise<{id: string}>}) {
  const params = await props.params;
  const id = params.id;
  // フォームの初期値として請求書データと顧客データを取得
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers()
  ])

  if (!invoice) {
    notFound();  // 存在しない請求書IDが指定された場合は404エラーページを表示
  }
  // ...
}

次に、ユーザーにエラー UI を表示するために、/app/dashboard/invoices/[id]/edit 内に not-found.tsx ファイルを作成します。

/app/dashboard/invoices/[id]/edit/not-found.tsx

import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';
 
export default function NotFound() {
  return (
    <main className="flex h-full flex-col items-center justify-center gap-2">
      <FaceFrownIcon className="w-10 text-gray-400" />
      <h2 className="text-xl font-semibold">404 Not Found</h2>
      <p>Could not find the requested invoice.</p>
      <Link
        href="/dashboard/invoices"
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
      >
        Go Back
      </Link>
    </main>
  );
}

存在しないURLにアクセスしてみます

13_handling_not_found.png (193.3 kB)

ちなみに、notFoundはerror.tsxよりも優先されるため、より具体的なエラーを処理したい場合に利用できます!

14. アクセシビリティの向上

Next.js で ESLint アクセシビリティ プラグインを使用する

Next.jsは、アクセシビリティの問題を早期に検出するために、ESLint設定に eslint-plugin-jsx-a11y プラグインを含んでいます。例えば、このプラグインは、altテキストのない画像がある場合、aria-* 属性や role 属性を誤って使用している場合などに警告を発します。

これを試して見る場合は package.jsonnext lint をスクリプトとして追加します。

/package.json

{
  // ...
  "scripts": {
    "build": "next build",
    "dev": "next dev --turbopack",
    "start": "next start",
    "lint": "next lint"  // 追加
  },
  // ...
}

実行

pnpm lint

# > @ lint /workspaces/react-learn/nextjs_tutorial/
# > next lint
# 
# `next lint` is deprecated and will be removed in Next.js 16.
# For new projects, use create-next-app to choose your preferred linter.
# For existing projects, migrate to the ESLint CLI:
# npx @next/codemod@canary next-lint-to-eslint-cli .
# 
# (node:1306592) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///workspaces/react-learn/nextjs_tutorial/next.config.ts is not specified and it doesn't parse as CommonJS.
# Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
# To eliminate this warning, add "type": "module" to /workspaces/react-learn/nextjs_tutorial/package.json.
# (Use `node --trace-warnings ...` to show where the warning was created)
# 
# ./app/dashboard/(overview)/page.tsx
# 5:23  Warning: 'Card' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# ./app/seed/route.ts
# 115:11  Warning: 'result' is assigned a value but never used.  @typescript-eslint/no-unused-vars
# 115:37  Warning: 'sql' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# ./app/ui/customers/table.tsx
# 5:3  Warning: 'CustomersTableType' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# ./app/ui/dashboard/latest-invoices.tsx
# 5:10  Warning: 'LatestInvoice' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# ./app/ui/dashboard/nav-links.tsx
# 28:26  Error: React Hook "usePathname" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function.  react-hooks/rules-of-hooks
# 
# ./app/ui/dashboard/revenue-chart.tsx
# 4:10  Warning: 'Revenue' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# ./app/ui/login-form.tsx
# 5:3  Warning: 'ExclamationCircleIcon' is defined but never used.  @typescript-eslint/no-unused-vars
# 
# info  - Need to disable some ESLint rules? Learn more here: https://nextjs.org/docs/app/api-reference/config/eslint#disabling-rules
#  ELIFECYCLE  Command failed with exit code 1.

フォーム検証

クライアント側の検証

<input> 要素や <select> 要素に required 属性を追加することで、ブラウザが提供するフォーム検証機能を利用して必須入力とすることができます。

/app/ui/invoices/create-form.tsx

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required
/>

サーバー側検証

サーバー上でフォームを検証することで、次のことが可能になります。

  • データベース送信前に、データが期待どおりの形式であることを確認
  • 悪意のあるユーザーがクライアント側の検証をバイパスするリスクを軽減
  • 有効なデータとみなされるものについては、信頼できる唯一の情報源を確保

useActionState はフォームのサーバーアクションの結果に基づいて state を更新するためのフックです。
useActionState を利用して、バリデーション結果を state に保持し画面にメッセージを描画します。

react から useActionState フックをインポートします。
useActionState はフックなので、 'use client' でクライアントコンポーネントに変更する必要があります。

/app/ui/invoices/create-form.tsx

'use client';  // useActionState フックはクライアントコンポーネントでのみ使用可能

// ...
import { useActionState } from 'react'

useActionState フックは 2つの引数と2つの戻り値を持ちます

  • const [state, formAction] = useActionState(createInvoice, initialState);
  • 引数 (action, initState)
    1. アクション関数
      シグネチャ function createInvoice(prevState: State|undefined, formData: FormData): Promise<State|undefined>
    2. ステートの初期状態
  • 戻り値 [state, formAction]
    1. 状態管理オブジェクト
    2. フォームアクション関数

useActionState を実行して状態管理オブジェクトとフォームアクション関数を生成し、 <form>action 属性に生成したフォームアクション関数を設定します。

/app/ui/invoices/create-form.tsx

// ...
import { useActionState } from 'react'

export default function Form({ customers }: { customers: CustomerField[] }) {
  // useActionState フックを使用して、フォームの状態とフォームアクション関数を生成
  const [state, formAction] = useActionState(createInvoice, initialState);

  return (
    <form action={formAction}>
      ...
    </form>
  )
}

initialState は任意に定義できます。
この例では、messageerrors というキーを持つState オブジェクトを作成し、actions.ts ファイルからインポートします。
State 型はこの後作成します。

/app/ui/invoices/create-form.tsx

// ...
import { createInvoice, State } from '@/app/lib/actions';  // 追加
import { useActionState } from 'react'

export default function Form({ customers }: { customers: CustomerField[] }) {
  // 初期ステートを定義
  const initialState: State = {message: null, errors: {}};
  const [state, formAction] = useActionState(createInvoice, initialState);

  return (
    <form action={formAction}>
      ...
    </form>
  )
}

action.ts ファイルで、Zodを使用してフォームデータを検証できます。FormSchemaを以下のように更新してください:

/app/lib/actions.ts

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    // 顧客フィールドが空の場合にエラーを発生させます。エラーメッセージもカスタマイズします。
    invalid_type_error: 'Please select a customer.',
  }),
  amount: z.coerce
    .number()
    // 文字列から数値への型変換を行うため、文字列が空の場合0になります。.gt()で金額が0より大きくなるようにします
    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  status: z.enum(['pending', 'paid'], {
    // ステータスフィールドが空の場合にエラーを発生させます。エラーメッセージもカスタマイズします。
    invalid_type_error: 'Please select an invoice status.',
  }),
  date: z.string(),
});

次に、createInvoice アクションを更新し、2つのパラメータ(prevStateformData )を受け取るようにします:

prevState は useActionStateフックから渡される状態オブジェクトで、必須のプロパティです。

/app/lib/actions.ts

export type State = {
  // フォームの各フィールドに関連するエラーメッセージを格納する
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  // フォームの全体的な状態や操作の結果に関するメッセージを格納する
  message?: string | null;
};
 
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}

次に、Zodの parse() 関数を safeParse() に変更します

safeParse() は、成功またはエラーのフィールドを含むオブジェクトを返します。(つまりバリデーションを try/catch ブロックでエラーハンドリングする必要がなくなります)

データベースに情報を送信する前に、条件分岐を用いてフォームフィールドが正しく検証されたか確認してください:

/app/lib/actions.ts

export async function createInvoice(prevState: State, formData: FormData) {
  // 各フィールドが正しく入力されているかを検証
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  // 検証に失敗した場合、エラーメッセージを含むStateオブジェクトを返す
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors, // {customerId: [...], amount: [...], status: [...]}
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  // ...
}

データベースのエラーに対しても特定のメッセージを返すことができます。

/app/lib/actions.ts

export async function createInvoice(prevState: State, formData: FormData) {
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // 変更: データベースエラーが発生した場合、メッセージを含むStateオブジェクトを返す
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

最後に、フォームコンポーネントでエラーを表示します。
create-form.tsx コンポーネントに戻り、フォームの状態を使ってエラーにアクセスします。

上記のコードでは、以下のariaラベルも追加しています:

  • aria-describedby="customer-error"
    これはselect要素とエラーメッセージコンテナの関係を確立します。 id="customer-error" のコンテナが select 要素を記述していることを示します。
    スクリーンリーダーは、ユーザーがselectボックスを操作した際にこの説明を読み上げ、エラーを通知します。
  • id="customer-error"
    この id 属性は、select 入力のエラーメッセージを保持するHTML要素を一意に識別します。 aria-describedby が関係を確立するために必要です。
  • aria-live="polite"
    div 内のエラーが更新された際、スクリーンリーダーはユーザーに丁寧に通知します。
    コンテンツが変更された場合(例:ユーザーがエラーを修正した時)、スクリーンリーダーはこれらの変更をアナウンスしますが、ユーザーの操作を妨げないよう、ユーザーが操作していない時のみ行います。

``

<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Customer Name */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choose customer
      </label>
      <div className="relative">
        <select
          id="customer"
          name="customerId"
          className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
          defaultValue=""
          aria-describedby="customer-error"  // 追加
        >
          <option value="" disabled>
            Select a customer
          </option>
          {customers.map((name) => (
            <option key={name.id} value={name.id}>
              {name.name}
            </option>
          ))}
        </select>
        <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
      </div>
      {/* 追加: state.errors.customerId に格納されているエラーメッセージを表示 */}
      <div id="customer-error" aria-live="polite" aria-atomic="true">
        {state.errors?.customerId &&
          state.errors.customerId.map((error: string) => (
            <p className="mt-2 text-sm text-red-500" key={error}>
              {error}
            </p>
          ))}
      </div>
      {/* 追加: ここまで*/}
    </div>
    // ...
  </div>
</form>

amount status のフォームにも同様の変更を行います。

14_accessibility_server_validation.png (217.4 kB)

edit-form.tsx コンポーネントのフォームにも同様の修正を行います。

useActionState を使ったフォオームの検証の最小サンプル

シグネチャ

useActionState(action, initialState, permalink?)

サンプルコード

ページコンポーネント

/app/sample/page.tsx

import Form from '@/app/sample/(ui)/form'

export default async function Page() {
  return (
    <div className="m-4">
      <Form />
    </div>
  )
}

フォームコンポーネント

/app/sample/(ui)/form.tsx

'use client';  // フック(useActionStateなど) はクライアントサイドでのみ利用可能

import { SampleState, sampleAction } from '@/app/sample/(ui)/action';
import { useActionState } from 'react';

export default function Form() {
  // フォームの初期状態
  const initialState: SampleState = {errors: {}};

  // useActionState フックでフォームの状態とアクション関数を取得
  const [state, sampleFormAction] = useActionState(sampleAction, initialState);

  return (
    <form action={sampleFormAction}> {/* useActionStateフックで取得したフォームアクションを指定 */}
      {/* name フィールド */}
      <div className="mb-4">
        <label htmlFor="name" className="mr-4">Name</label>
        <input id="name" name="name" type="text" required/>
        {/* stateからエラーメッセージを表示 */}
        <div>
          {state?.errors?.name && state.errors.name.map((e: string) => {
              return <p className="text-red-600" key={e}>{e}</p>
          })}
        </div>
      </div>

      {/* age フィールド */}
      <div className="mb-4">
        <label htmlFor="age" className="mr-4">Age</label>
        <input id="age" name="age" type="number" required/>
        {/* stateからエラーメッセージを表示 */}
        <div>
          {state?.errors?.age && state.errors.age.map((e: string) => {
            return <p className="text-red-600" key={e}>{e}</p>
          })}
        </div>
      </div>

      {/* 送信ボタン */}
      <div>
        <button 
          className="rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700"
          type="submit"
        >Submit</button>
      </div>
    </form>
  )
}

フォームアクション

/app/sample/(ui)/action.tsx

import { z } from 'zod';

/**
 * フォームの検証スキーマ
 */
const FormSchema = z.object({
  name: z.string()
    .trim()
    .regex(/^[A-Za-z\s]+$/, {message: "アルファベットとスペースのみ使用できます"})
    .min(2, {message: "2文字以上で入力してください"})
    .max(50, {message: "50文字以下で入力してください"}),
  age: z.coerce  // zodのnumberはNaNを許容しないため、z.coerceを使用して文字列を数値に変換 (https://zod.dev/api?id=coercion)
    .number()
    .min(0, {message: "0歳以上で入力してください"})
    .max(100, {message: "100歳以下で入力してください"}),
})

/**
 * フォームのstateの型定義
 */
export type SampleState = {
  errors?: {
    name?: string[];
    age?: string[];
  };
}

/**
 * フォームのアクション関数
 *
 * useActionState フックの第1引数に渡される関数は、stateとFormDataオブジェクトの2つの引数を受け取る必要があります
 */
export async function sampleAction(
  prevState: SampleState | undefined,
  formData: FormData
) {
  // フォームデータの検証
  const validatedFields = FormSchema.safeParse({
    name: formData.get("name"),
    age: formData.get("age")
  })

  // 検証エラーがある場合は、エラーメッセージを返す
  // エラーメッセージは state に格納され、フォームに表示される
  if (!validatedFields.success) {
    const state: SampleState = {
      // { name: [...], age: [...] }
      errors: validatedFields.error.flatten().fieldErrors
    }
    return state;
  }

  // 検証が成功した場合は、フォームデータを処理する (ここではコンソールに出力)
  console.log("Form submitted successfully:", validatedFields.data);
}

15. 認証の追加

ログインルートの作成

新たにログインページのルートを作成します。

<LoginForm /> コンポーネントはリクエストの情報(URLクエリパラメータ)にアクセスするため <Suspense> でラップされています。

/app/login/page.tsx

import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
import { Suspense } from 'react';
 
export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">
            <AcmeLogo />
          </div>
        </div>
        <Suspense>
          <LoginForm />
        </Suspense>
      </div>
    </main>
  );
}

NextAuth.js

NextAuth.jsを利用してアプリケーションに認証機能を追加します。
NextAuth.js は、セッション管理、サインインとサインアウト、その他の認証に関わる複雑な要素の多くを抽象化します。

NextAuth.js の設定

ターミナルで次のコマンドを実行して NextAuth.js をインストールします。

#  Next.js 14 以降と互換性のある beta バージョンの NextAuth.js をインストール
pnpm i next-auth@beta

次に、Cookieを暗号化するためのアプリケーションの秘密鍵を生成します

openssl rand -base64 32

.env ファイルに生成されたキーを AUTH_SECRET として追加します

.env

AUTH_SECRET=your-secret-key

ページオプションの追加

プロジェクトのルートディレクトリに auth.config.ts ファイルを作成し、authConfig オブジェクトをエクスポートします。
このオブジェクトには NextAuth.js の設定オプションが含まれます。現時点では pages オプションのみを含みます:

NextAuthConfig - API Reference| Auth.js

satisfies はオブジェクトが型要件を満たしているかを検査するキーワードです。 as のように型を強制的に変換しません。

/auth.config.ts

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
} satisfies NextAuthConfig;

pages オプション

pages オプションではカスタムのサインイン、サインアウト、エラーページへのルートを指定できます (指定がない場合はNextAuth.jsのデフォルトページとなる)

ミドルウェアでルートを保護する

まずはルートを保護するロジックを実装します。

/auth.config.ts

import type { NextAuthConfig, Session } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth , request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
      if (isOnDashboard) {
        // ダッシュボードページにアクセスする場合、ログインしているかどうかを確認します
        return isLoggedIn;
      } else if (isLoggedIn) {
        // ログインしている場合、ダッシュボードにリダイレクトします
        return Response.redirect(new URL("/dashboard", nextUrl))
      }
      return true;
    }
  },
  providers: [],
} satisfies NextAuthConfig;

callbacks オプション

認証関連のアクションが実行された際の動作を制御する非同期関数を定義します。

  • authorized コールバック
    ミドルウェアを使用してリクエストがページへのアクセスを許可されているかを確認するために使用されます。
    • 引数
      • auth にはユーザーの Session が含まれます
      • request には NuxtRequest が含まれます

providers オプション

サインイン用の認証プロバイダー一覧 (Google、Facebook、Twitter、GitHub、メールなど)を指定します(順不同)。
組み込みプロバイダーのいずれか、またはカスタムプロバイダーを含むオブジェクトを指定できます。

ミドルウェアの定義

ミドルウェアを作成します。 middleware.ts というファイルを作成し、authConfig オブジェクトをインポートします。

/middleware.ts

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

// NextAuth(): https://authjs.dev/reference/nextjs#default-13
//   - auth: https://authjs.dev/reference/nextjs#auth-1
export default NextAuth(authConfig).auth;

export const config = {
  // matcher: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  // matcherにマッチするパスに対してミドルウェアが実行されます
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
  runtime: 'nodejs',
}

ここでは、authConfig オブジェクトで NextAuth を初期化し、 auth プロパティをエクスポートしています。

auth プロパティはそのままミドルウェアとして使用できます。

また、Middleware の matcher オプションを使用して、特定のパスで実行されるように指定しています。

ミドルウェアは検証が完了するまではレンダリングを開始しないので、認証においてアプリのセキュリティとパフォーマンスの向上が期待できます。

パスワードハッシュ

データベースの初期化時には、 bcrypt というパッケージを使用してユーザーのパスワードをハッシュ化し、データベースに保存しました。
この章の後半では、ユーザーが入力したパスワードがデータベース内のものと一致するか比較するために、再びこのパッケージを使用します。
ただし、bcryptパッケージ用に別途ファイルを作成する必要があります。これは、bcrypt がNext.jsのミドルウェアでは利用できないNode.js APIに依存しているためです。

auth.config.ts ファイルに定義した authConfig を拡張するために、新しく auth.ts ファイルを作成します。

/auth.ts

import NextAuth from 'next-auth';
import { authConfig } from '@/auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

Credentials プロバイダの追加

次に、 NextAuth.jsproviders オプションを追加します。
providers は Google や GitHub といったログインオプションを配列で定義できますが、今回は、 Credentials プロバイダーのみに焦点を当てます。

Credentials プロバイダーは、ユーザー名とパスワードでログインすることを可能にします。

providers オプションには以下の選択肢があります

/auth.ts

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';  // 追加
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [Credentials({})],  // 追加
});

サインイン機能の追加

認証ロジックの実装は authorize 関数に行います。

/auth.ts

import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import type { User } from "@/app/lib/definitions";
import bcrypt from 'bcrypt';
import postgres from 'postgres';

const sql = postgres(process.env.POSTGRES_URL!, {ssl: false})
 
// 指定されたメールアドレスに合致するユーザーをデータベースから取得するヘルパー関数
async function getUser(email: string): Promise<User | undefined> {
  try {
    const users = await sql<User[]>`SELECT * FROM users WHERE email = ${email}`;
    return users[0];
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw new Error("Failed to fetch user.");
  }
}

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      // 認証ロジックの実装は authorize メソッド内で行う
      // 引数のcredentialsはユーザーがサインインフォームに入力した値
      async authorize(credentials) {
        // zodを使ってユーザーの入力値のバリデーション
        const parsedCredentials = z
          .object({
            email: z.string().email(),
            password: z.string().min(6),
          })
          .safeParse(credentials);
        if (!parsedCredentials.success) {
          return null;
        }

        // emailとマッチするユーザーが存在するか確認
        const { email, password } = parsedCredentials.data;
        const user = await getUser(email);
        if (!user) {
          return null
        }

        // パスワードが一致するか確認
        const passwordsMatch = await bcrypt.compare(password, user.password)
        if (!passwordsMatch) {
          console.log("Invalid credentials");
          return null
        }

        // 認証に成功した場合、ユーザーオブジェクトを返す
        return user;
      }

    })
  ],
})

ログインフォームの更新

認証ロジックをログインフォームに接続します。
actions.ts ファイルに authenticate という新しいアクションを作成します。このアクションは auth.ts で生成した signIn 関数を利用してログイン処理を行います。

/app/lib/actions.ts

'use server';
 
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
 
// ...
 
export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    // error | Auth.js: https://authjs.dev/reference/core/errors
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}

最後に、 login-form.tsx コンポーネントで useActionState を使用して、 authenticate サーバーアクションからフォームアクションを生成し、フォームに設定します。
同時にフォームの状態管理も行います。

/app/ui/login-form.tsx

'use client';  // useActionStateフックを利用するのでクライアントコンポーネントにする
 
// ...
import { useActionState } from 'react';
import { authenticate } from '@/app/lib/actions';
import { useSearchParams } from 'next/navigation';
 
export default function LoginForm() {
  // URLクエリパラメータからリダイレクト先のURLを取得
  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';

  // ステートオブジェクトとフォームアクションを生成
  const [errorMessage, formAction, isPending] = useActionState(authenticate, undefined);
 
  return (
    <form action={formAction} className="space-y-3"> {/* フォームアクションを設定 */}
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">
          {/* ... */}
        </div>
        {/* type=hidden で redirectTo パラメータを追加 */}
        <input type="hidden" name="redirectTo" value={callbackUrl} />
        {/* aria-disabled={isPending} で処理中はボタンを押せなくする */}
        <Button className="mt-4 w-full" aria-disabled={isPending}>
          Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
        </Button>
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {/* ステートのエラーメッセージを表示 */}
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}

ログアウト機能の追加

<SideNav /> にログアウト機能を追加するには、<form> 要素内で auth.tssignOut 関数を呼び出します

/ui/dashboard/sidenav.tsx

// ...
import { signOut } from '@/auth';
 
export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      // ...
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        {/* action で signOut関数を実行 */}
        <form
          action={async () => {
            'use server';
            await signOut({ redirectTo: '/' });
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}

動作確認

15_login.png (108.4 kB)

ログイン失敗

15_invalid_credentials.png (127.9 kB)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?