Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Resendクライアントをモジュール直下でnewするとNext.jsのビルドごと落ちる

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Resendのクライアントをモジュールのトップレベルでnewすると、そのファイルをimportするだけでAPIキーの検証が走るため、next buildがAPIルートのページデータを収集する際にRESEND_API_KEYの無い環境ではimportした時点でビルドごと失敗する。

結論

Resend のクライアントをモジュールのトップレベルで new していると、そのファイルを import するだけで API キーの検証が走る。 RESEND_API_KEY が無い環境では、実際にメールを送る前の import した瞬間に例外が投げられ、しかもこの importnext build がページデータを収集する段階で発生するため、リクエストが1つも来る前にビルドそのものが失敗する。 直し方は、new Resend(...) をモジュール直下から呼び出す関数の中へ移し、実際に呼ばれるまで評価しない遅延初期化にすること。

症状

初期実装の直後に踏んだ。認定試験の採点結果メールを送る sendResultEmail は API ルート src/app/api/admin/score/route.ts から呼ばれている。このルート自体は動的な処理で、ビルド時に静的生成されるページではない。それでも next build はページデータ収集のために各ルートのモジュールを一度読み込んで構成を確認するため、route.tsimport { sendResultEmail } from '@/lib/email' した時点で email.ts のトップレベルコードが評価される。

RESEND_API_KEY を注入していないビルド環境(CI やプレビューなど)でこの読み込みが起きると、後述の理由で例外が投げられ、ビルド全体が失敗する。ローカルで .env.local にキーを置いて開発していると再現せず、本番用のキーを設定した環境でも再現しないため、鍵が意図的に絞られているビルド環境でだけ表面化する

原因

修正前の src/lib/email.ts はこう書かれていた。

import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

interface ResultEmailProps {
  email: string
  level: number
  verdict: 'passed' | 'failed'
  feedback?: string
}

export async function sendResultEmail({ email, level, verdict, feedback }: ResultEmailProps) {
  const isPassed = verdict === 'passed'
  // ...
}

new Resend(process.env.RESEND_API_KEY)sendResultEmail 関数の外、モジュールのトップレベルに置かれている。JavaScript のモジュールは import された時点でトップレベルのコードを評価するため、sendResultEmail が一度も呼ばれていなくても、email.tsimport した時点でこの行は実行される。

Resend のコンストラクタは、有効な API キーを渡されないと例外を投げる実装になっている。RESEND_API_KEY が環境変数に無ければ process.env.RESEND_API_KEYundefined になり、コンストラクタはその場で例外を投げる。この例外は「メール送信に失敗した」という実行時エラーではなく、モジュールの読み込み自体を失敗させる例外であり、next build のページデータ収集がこのモジュールを読み込もうとした瞬間に発生し、ビルドプロセスごと止める。

直す

new Resend(...) を、モジュール直下から sendResultEmail 関数の中へ移した。

import { Resend } from 'resend'

interface ResultEmailProps {
  email: string
  level: number
  verdict: 'passed' | 'failed'
  feedback?: string
}

export async function sendResultEmail({ email, level, verdict, feedback }: ResultEmailProps) {
  const resend = new Resend(process.env.RESEND_API_KEY)
  const isPassed = verdict === 'passed'
  // ...
}

email.tsimport した時点では何も評価されない。new Resend(...)sendResultEmail が実際に呼ばれたときに初めて実行される、遅延初期化になった。API ルートが import されるだけのビルド時には実行されず、実際にリクエストを処理する実行時にだけ、その環境に注入されている RESEND_API_KEY を読みにいく。

再発防止

この種の SDK クライアントは、キーの有無をコンストラクタで即座に検証する実装が多い。モジュール直下で new することは、そのファイルを import する全ての経路(ビルド時のページデータ収集も含む)に、キー未設定時の失敗を持ち込むことと同じになる。外部 SDK のクライアント生成は、実際にそれを使う関数やリクエストハンドラの内側に置き、import だけでは何も実行されない状態を保つのが安全側になる。

よくある質問

Q1なぜモジュール直下でnewすると問題になるのですか?

JavaScriptのモジュールはimportされた時点でトップレベルのコードが評価されます。Resendのコンストラクタは渡されたAPIキーが無いと例外を投げるため、モジュール直下でnewしていると、そのファイルをimportしただけで例外が発生します。呼び出し側が実際にメール送信を行うかどうかは関係ありません。

Q2実行時ではなくビルド時に落ちるのはなぜですか?

この関数はNext.jsのAPIルート(route.ts)から呼ばれています。next buildはページデータの収集のために各ルートのモジュールを一度importして構成を読み取るため、モジュール直下の例外はこの収集の段階で発生し、リクエストが来る前のビルドそのものを失敗させます。

Q3RESEND_API_KEYが無い環境とは具体的にどこですか?

CIやプレビュー環境など、本番用のシークレットが注入されていないビルド環境です。ローカル開発で `.env.local` にキーを置いていれば気づかず、本番用のキーが設定された環境でビルドしても再現しないため、鍵が意図的に絞られている環境でだけ表面化します。

Q4直し方はどう変わりますか?

`new Resend(...)` をモジュール直下から、実際にメールを送る関数(sendResultEmail)の中へ移すだけです。importされた時点では何も評価されず、関数が呼ばれて初めてAPIキーを読みに行く遅延初期化になります。

確認した環境

  • resend ^6.17.2 / Next.js 16.2.10 / React 19.2.4
  • 2026-07-13、初期実装の直後に発見・同日中に修正

この記事の根拠

  • TypeScriptファイル 1〜4行目コミット 1b56023
  • TypeScriptファイル 1〜11行目コミット a949c8d
  • TypeScriptファイル 1〜3行目コミット 1b56023

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。