Knowledge
Cloudinary の Unsigned Upload Preset でサーバー不要のクライアント直接アップロードを実現する
API Secret をクライアントに持たせず、Unsigned Upload Preset を使って FormData + fetch だけでブラウザから直接 Cloudinary にアップロードする実装パターンを解説します。Edge Runtime 制約のある環境でも Server Action 不要で動作します。
1. 背景・課題
Image Diff Table では、ユーザーがローカル画像ファイルを選択すると そのURLをMarkdownテーブルに挿入する機能が必要でした。 画像URLを取得するには、どこかのホスティングサービスにアップロードする必要があります。
最初に検討したのは Server Action 経由のアップロードです。 しかし本プロジェクトは Cloudflare Pages(Edge Runtime)にデプロイしており、 Server Action 内では Buffer やストリームなど Node.js API が使えません。 バイナリファイルの処理はこの制約に引っかかるケースが多く、 Server Action を介さずにクライアントから直接 Cloudinary へアップロードする方式を採用しました。
クライアント直接アップロードで問題になるのが認証です。 通常の Cloudinary アップロードには署名(Signature)が必要ですが、 署名の生成には API Secret が必要であり、これをクライアントに持たせるとセキュリティリスクになります。 この問題を解決するのが Unsigned Upload Preset です。
2. Signed vs Unsigned Upload の違い
Cloudinary のアップロード方式は大きく2種類あります。
- Signed Upload: サーバーサイドで API Secret を用いてリクエストに署名し、 その署名をクライアントに渡してアップロードします。 アップロードできるファイルの種類・サイズ・保存先をリクエストごとに制御できるため柔軟ですが、 署名生成のためにサーバーサイドの処理が必ず必要です。
- Unsigned Upload: Cloudinary のダッシュボードで事前に設定した「Upload Preset」の名前を リクエストに含めるだけでアップロードできます。 API Secret は不要なため、クライアントから直接リクエストできます。 アップロードの制約(フォルダ・ファイルサイズ・形式)はプリセット側で設定します。
Server Action が使える環境であれば Signed Upload の方が制御の粒度は細かくなります。 一方、Edge Runtime 制約がある環境や実装をシンプルに保ちたい場合は Unsigned Upload Preset が有効な選択肢です。
3. Unsigned Upload Preset のセキュリティ設計
Unsigned Upload では API Secret が不要な代わりに、 プリセット自体に適切な制約を設けることでリスクをコントロールします。 Cloudinary ダッシュボードの Upload Preset 設定で以下を構成します。
- Signing Mode を Unsigned に設定: プリセットを Unsigned モードにすることで、
cloud_nameとupload_preset名だけでアップロード可能になります。 - 保存フォルダの固定:
Folderにパス(例:devtools/uploads)を指定すると、 このプリセット経由のアップロードは必ず指定フォルダに保存されます。 他のアセットと混在せず管理が容易になります。 - 許可する形式の制限:
Allowed formatsにjpg, png, webp, gifなど 必要最小限の形式のみを指定します。 不要な形式(PDF・動画等)のアップロードを防ぎます。 - ファイルサイズの上限設定:
Max file sizeで上限を設けることで、 大容量ファイルによる意図しないストレージ消費を抑制します。
cloud_name と upload_preset 名は公開情報として扱います。 これらが漏れても、プリセット側の制約(形式・サイズ・フォルダ)の範囲内でしかアップロードできないため、API Secret の漏洩とは性質が異なります。 ただし、悪意のある第三者がプリセットを利用して大量アップロードするリスクはゼロではないため、 Cloudinary の使用量アラートを設定しておくことを推奨します。
4. 実装パターン
クライアント直接アップロードの実装は FormData と fetch のみで完結します。
async function uploadToCloudinary(file: File): Promise<string> {
const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME
const preset = process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET
if (!cloudName || !preset) {
throw new Error('Cloudinary の設定が不足しています。')
}
const formData = new FormData()
formData.append('file', file)
formData.append('upload_preset', preset)
const res = await fetch(
`https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
{ method: 'POST', body: formData }
)
if (!res.ok) throw new Error('アップロードに失敗しました')
const data = await res.json() as { secure_url: string }
return data.secure_url
}- 環境変数は
NEXT_PUBLIC_プレフィックス:cloud_nameとupload_presetはブラウザから参照するためNEXT_PUBLIC_CLOUDINARY_CLOUD_NAME・NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESETとして管理します。これらは公開情報として意図的に公開側に置きます。 - Content-Type ヘッダーは不要:
FormDataを body に渡すと、ブラウザが自動でmultipart/form-data; boundary=...を設定します。 手動でContent-Typeを指定すると boundary が欠落してエラーになるため、 ヘッダーは指定しません。 - hidden file input パターン:
input[type=file]はブラウザのデフォルトスタイルが固定されており Tailwind でのカスタマイズに制限があります。 そのためaria-hidden="true"で非表示にしたinputを配置し、 スタイルを自由に設定できる別のボタンのonClickからfileInputRef.current?.click()でファイル選択ダイアログを開きます。
const fileInputRef = useRef<HTMLInputElement>(null)
// ボタン側
<button onClick={() => fileInputRef.current?.click()}>
<Upload />
</button>
// 非表示の file input
<input
ref={fileInputRef}
type='file'
accept='image/jpeg,image/png,image/webp,image/gif'
className='hidden'
onChange={handleFileChange}
aria-hidden='true'
/>5. アップロード中の UX 設計
Image Diff Table では複数の行が並び、各行に Before/After の2スロットがあります。 どのスロットがアップロード中かを特定して、そのスロットだけスピナーを表示し無効化する必要があります。
スロットキー(`${rowId}-${field}`)を文字列で管理する方法を採用しました。
const [uploadingSlot, setUploadingSlot] = useState<string | null>(null)
async function handleFileSelected(
rowId: string,
field: 'beforeUrl' | 'afterUrl',
file: File
) {
const slotKey = `${rowId}-${field}` // 例: "abc123-beforeUrl"
setUploadingSlot(slotKey)
try {
const url = await uploadToCloudinary(file)
handleUrlChange(rowId, field, url)
toast.success('画像をアップロードしました')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'アップロードに失敗しました')
} finally {
setUploadingSlot(null)
}
}各 ImageUploadSlot コンポーネントはisUploading={uploadingSlot === `${rowId}-${field}`}を受け取り、true のときのみスピナーを表示してボタンを無効化します。 他の行・スロットは影響を受けず操作可能なままです。
- boolean ではなくスロットキー文字列で管理する理由:
isUploading: booleanを行単位で持つと、 行数分の state が必要になります。 スロットキー文字列を1つの state にすることで、 「どの行のどのスロットが処理中か」を1箇所で管理でき、 同時に複数スロットがアップロード中になる競合も自然に防げます。