🚨

知らないとハマる Next.js のエラーハンドリング

2024/11/22に公開

この記事では Next.js アプリケーションのエラーハンドリングで個人的にハマったことについて、そのハマりポイントとどう解決したかをご紹介します。
これが正解というわけではなく、一つの設計案として見ていただければ幸いです。

環境

  • Next.js v15.0.3
  • App Router 使用

その1: Server Component で throw したカスタムエラーを Client Component の Error Boundary では受け取れない

間違いパターン

Server Component が使えるようになった昨今の Next.js アプリケーションにおいて、典型的なデータフェッチのやり方をまず見てみましょう。

const UsersContainer = async () => {
  const users = await getUsers()
  return <UsersPresentation users={users} />
}

Server Component で非同期関数である getUsers を実行して users を Client Component である UsersPresentation に渡しています。
getUsers 関数は、中で外部の API を叩いて users を取得します。叩いた API がエラーを返した場合、HttpError というカスタムエラーを throw します。

class HttpError extends Error {
  constructor(message: string, status: number) {
    super(message)
    this.status = status
  }
}

const getUsers = async () => {
  const res = await fetch('https://example.com/users')

  if (!res.ok) {
    throw new HttpError('API Request Error', res.status)
  }

  const json = await res.json()

  return json
}

UsersContainer の中でエラーが throw された場合、それを app ディレクトリ直下の error.tsx で受けることとします。
error.tsx という命名で app ディレクトリ直下にファイルを置くと page コンポーネントを Error Boundary で囲んだ状態になり、その中でエラーが発生した場合は error.tsx の中身をフォールバックとして表示してくれます。

'use client'

type Props = {
  error: Error
}

export default function Error({ error }: Props) {
  if (error instanceof HttpError) {
    switch (error.status) {
      case 400:
        return 'Bad Request'
      
      case 401:
        return 'Unauthorized'
      
      default:
        return 'Unexpected'
    }
  }
  
  return 'Unexpected'
}

これで、getUsers が throw した HttpError の status に応じてページにエラーメッセージを表示することができるはずです。
しかし実際にこの処理を動かしてみると、getUsers がどんなステータスで HttpError を throw したとしても、画面には「Unexpected」が表示されます。

このパターンの何がだめなのか

なぜ期待通り動かないのかと言うと、Server Component でカスタムエラーを throw して error.tsx にフォールバックさせる場合、error.tsx のコンポーネントに渡される error props は標準の Error クラスに変換されます。Server で発生したエラーを Client で表示するので、境界をまたぐことになるのでそのまま使えなくなるというのは考えてみれば当然ですね。
そのため、error props には HttpError ではなく Error クラスが入ってくるので if 文の条件に合わず「unexpected」が表示されるというわけです。

では error.tsx に 'use client' を記述せず Server Component として扱えば良いのでは?となりそうですが、error.tsx は Client Component を強制されるためそれはできません。

その2: production mode では Server Comopnent で throw したエラーの内容は Client Comopnent からはわからない

間違いパターン

ということで、カスタムエラーを投げても意味がないので、そうなると標準の Error クラスで message を工夫したくなります。

const UsersContainer = async () => {
  try {
    const users = await getUsers()
    return <UsersPresentation users={users} />
  } catch (e) {
    if (error instanceof HttpError) {
      throw new Error(`${error.status}/${error.message}`)
    }

    throw new Error(error.message)
  }
}
export default function Error({ error }: Props) {
  const [status, message] = error.message.split('/')

  switch (status) {
    case '400':
      return 'Bad Request'

    case '401':
      return 'Unauthorized'

    default:
      return 'Unexpected'
  }

  return 'Unexpected'
}

UsersContainer でエラーを catch したら HttpError の status を標準の Error クラスの message に文字列として埋め込んだ上で throw し直しています。
こうすると、Error コンポーネントでは想定通り getUsers から投げられた HttpError の status にあわせたエラーメッセージが表示されるようになります。

これでめでたしめでたしに見えますが、実はこの書き方で動くのは Next.js の dev mode で動かした場合のみで、next build && next start で production mode で動かした場合は想定通りの挙動をしなくなります。

このパターンの何がだめなのか

production mode で動かしてエラーを発生させた際に error.tsx に props として渡される error の message を見ると以下のようになっています。

Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

(訳: Server Components のレンダーでエラーが発生しました。具体的なメッセージは、機密情報の漏えいを防ぐため、プロダクションビルドでは省略されます。このエラーインスタンスにはダイジェストプロパティが含まれています。)

これは production mode の場合、サーバー側で発生したエラーの内容をクライアントにそのまま渡してしまうとユーザーに生のエラーが見えてしまうため、Next.js 側でエラーの中身を変換してくれるゆえにこうなります。

解決策

ということで挙動が理解できたので、解決方法としては色んなやり方がありますが、これまでのコードから大きく構造を変えずに動くようにするにはどうするかという視点での解決策を書きます。

まず HttpError に serialize, deserialize メソッドを用意します。HttpError クラスをプレーンなオブジェクトに変換したり、プレーンなオブジェクトを HttpError に変換するメソッドです。

type SerializedHttpError = {
  message: string
  status: number
}

class HttpError extends Error {
  constructor(message: string, status: number) {
    super(message)
    this.status = status
  }
  
  serialize(): SerializedHttpError {
    return {
      message: this.message,
      status: this.status,
    }
  }
  
  public deserialize(data: SerializedHttpError) {
    return new HttpError(data.message, data.status)
  }
}

そして UsersContainer 内でこのエラーを Client Component である ThrowHttpError に渡します。

const UsersContainer = async () => {
  try {
    const users = await getUsers()
    return <UsersPresentation users={users} />
  } catch (e) {
    if (error instanceof HttpError) {
      return <ThrowHttpError serializedError={e.serialize()} />
    }
    
    throw new Error(error.message)
  }
}

ThrowHttpError は Client Component となっていて、受け取ったエラーをそのまま throw するだけのコンポーネントです。

'use client'

type Props = {
  serializedError: SerializedHttpError
}

const ThrowHttpError = ({ serializedError }: Props) => {
  throw HttpError.deserialize(serializedError)
}

Client Component からエラーを投げ直しているので、Server と Client の境界をまたいでいない為、error.tsx はそのエラーをそのまま受け取ることができます。

export default function Error({ error }: Props) {
  if (error instanceof HttpError) {
    switch (error.status) {
      case 400:
        return 'Bad Request'
      
      case 401:
        return 'Unauthorized'
      
      default:
        return 'Unexpected'
    }
  }
  
  return 'Unexpected'
}

以上で、Server Component で発生したエラーを error.tsx のフォールバックで受け取ってエラーメッセージの出し分けをする、ということができるようになりました。

まとめ

Next.js のエラーハンドリングでのハマりポイントをまとめると、

  • Server Component で throw したカスタムエラーは Client Component の Error Boundary では標準の Error クラスに変換される
  • production mode では Server Comopnent で throw したエラーの内容は Client Comopnent からはわからない

ということになります。
冒頭の繰り返しになりますが、この記事で紹介したやり方はあくまでも一例であり、他により良いやり方はアプリケーションごとに存在するので、一つの案として参考にしていただければと思います。

GitHubで編集を提案

Discussion