Resendクライアントをモジュール直下でnewするとNext.jsのビルドごと落ちる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Resendのクライアントをモジュールのトップレベルでnewすると、そのファイルをimportするだけでAPIキーの検証が走るため、next buildがAPIルートのページデータを収集する際にRESEND_API_KEYの無い環境ではimportした時点でビルドごと失敗する。
結論
Resend のクライアントをモジュールのトップレベルで new していると、そのファイルを import するだけで API キーの検証が走る。 RESEND_API_KEY が無い環境では、実際にメールを送る前の import した瞬間に例外が投げられ、しかもこの import は next build がページデータを収集する段階で発生するため、リクエストが1つも来る前にビルドそのものが失敗する。 直し方は、new Resend(...) をモジュール直下から呼び出す関数の中へ移し、実際に呼ばれるまで評価しない遅延初期化にすること。
症状
初期実装の直後に踏んだ。認定試験の採点結果メールを送る sendResultEmail は API ルート src/app/api/admin/score/route.ts から呼ばれている。このルート自体は動的な処理で、ビルド時に静的生成されるページではない。それでも next build はページデータ収集のために各ルートのモジュールを一度読み込んで構成を確認するため、route.ts が import { 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.ts を import した時点でこの行は実行される。
Resend のコンストラクタは、有効な API キーを渡されないと例外を投げる実装になっている。RESEND_API_KEY が環境変数に無ければ process.env.RESEND_API_KEY は undefined になり、コンストラクタはその場で例外を投げる。この例外は「メール送信に失敗した」という実行時エラーではなく、モジュールの読み込み自体を失敗させる例外であり、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.ts を import した時点では何も評価されない。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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。