vercel-blob について
Vercel Blob でファイルを保存、アップロード、管理する開発者向けの実装ガイドです。クライアントからの安全なアップロードやマルチパート転送、ファイル操作、問題の予防を扱います。
主な機能
- 01サーバー側でのファイルアップロード
- 02安全なクライアント用トークンの発行
- 03Blob の一覧表示と削除
- 04大容量ファイルのマルチパート転送
- 05ファイル検証とパス整理
- 06一般的な問題の対処
活用シーン
- 01ユーザーのアバターやファイルのアップロード
- 02大容量ファイルのクライアントアップロード
- 03ページングによる保存ファイルの確認と削除
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill vercel-blobインストール後、Claude Code で/vercel-blobを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
README.md.claude-plugin/plugin.jsonassets/example-template.txtreferences/example-reference.mdscripts/example-script.shtemplates/avatar-upload-flow.tsxdrag-drop-upload.tsxfile-list-manager.tsxpackage.json詳細
Vercel Blob
最終更新日: 2026-01-21 バージョン: @vercel/[email protected] スキルバージョン: 2.1.0
クイックスタート
# Create Blob store: Vercel Dashboard → Storage → Blob
vercel env pull .env.local # Creates BLOB_READ_WRITE_TOKEN
npm install @vercel/blob
サーバーアップロード:
'use server';
import { put } from '@vercel/blob';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
const blob = await put(file.name, file, { access: 'public' });
return blob.url;
}
重要: クライアントに BLOB_READ_WRITE_TOKEN を決して公開しないでください。クライアントからのアップロードには handleUpload() を使用してください。
クライアントアップロード(セキュア)
サーバーアクション(署名付きトークンを生成):
'use server';
import { handleUpload } from '@vercel/blob/client';
export async function getUploadToken(filename: string) {
return await handleUpload({
body: {
type: 'blob.generate-client-token',
payload: { pathname: `uploads/${filename}`, access: 'public' }
},
request: new Request('https://dummy'),
onBeforeGenerateToken: async (pathname) => ({
allowedContentTypes: ['image/jpeg', 'image/png'],
maximumSizeInBytes: 5 * 1024 * 1024
})
});
}
クライアントコンポーネント:
'use client';
import { upload } from '@vercel/blob/client';
const tokenResponse = await getUploadToken(file.name);
const blob = await upload(file.name, file, {
access: 'public',
handleUploadUrl: tokenResponse.url
});
ファイル管理
一覧表示/削除:
import { list, del } from '@vercel/blob';
// List with pagination
const { blobs, cursor } = await list({ prefix: 'uploads/', cursor });
// Delete
await del(blobUrl);
マルチパート(>500MB):
import { createMultipartUpload, uploadPart, completeMultipartUpload } from '@vercel/blob';
const upload = await createMultipartUpload('large-video.mp4', { access: 'public' });
// Upload chunks in loop...
await completeMultipartUpload({ uploadId: upload.uploadId, parts });
重要なルール
必ず行うこと:
- ✅ クライアントからのアップロードには
handleUpload()を使用する(BLOB_READ_WRITE_TOKENを決して公開しない) - ✅ アップロード前にファイルの種類とサイズを検証する
- ✅ パス名で整理する(
avatars/、uploads/) - ✅ ファイル名にタイムスタンプ/UUIDを追加する(衝突を避ける)
決して行わないこと:
- ❌ クライアントに
BLOB_READ_WRITE_TOKENを公開する - ❌ マルチパートを使わずに500MBを超えるファイルをアップロードする
- ❌ ファイル検証を省略する
既知の問題の防止
このスキルは、文書化されている16件の問題を防止します。
問題 #1: 環境変数の不足
エラー: Error: BLOB_READ_WRITE_TOKEN is not defined
出典: https://vercel.com/docs/storage/vercel-blob
発生理由: 環境にトークンが設定されていません。
防止策: vercel env pull .env.local を実行し、.env.local が .gitignore に含まれていることを確認してください。
問題 #2: クライアントアップロード用トークンの露出
エラー: セキュリティ上の脆弱性、不正なアップロード
出典: https://vercel.com/docs/storage/vercel-blob/client-upload
発生理由: クライアントコードで BLOB_READ_WRITE_TOKEN を直接使用しています。
防止策: handleUpload() を使用して、制約付きのクライアント専用トークンを生成してください。
問題 #3: ファイルサイズ制限の超過
エラー: Error: File size exceeds limit(500MB)
出典: https://vercel.com/docs/storage/vercel-blob/limits
発生理由: マルチパートアップロードを使わずに500MBを超えるファイルをアップロードしています。
防止策: アップロード前にファイルサイズを検証し、大きなファイルにはマルチパートアップロードを使用してください。
問題 #4: Content-Typeの誤り
エラー: ブラウザーがファイルを表示せずにダウンロードする(例: PDFがテキストとして開く)
出典: 本番環境のデバッグ
発生理由: contentType オプションが設定されておらず、Blobが誤った値を推測しています。
防止策: 常に contentType: file.type または明示的なMIMEタイプを設定してください。
問題 #5: 公開ファイルがキャッシュされない
エラー: ファイル配信が遅い、エグレスコストが高い
出典: Vercel Blobのベストプラクティス
発生理由: 公開すべきファイルに access: 'private' を使用しています。
防止策: 公開アクセス可能なファイルには access: 'public' を使用してください(CDNキャッシュ)。
問題 #6: リストのページネーションが未処理
エラー: 最初の1000件のファイルしか返されず、ファイルが不足する
出典: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#list
発生理由: 大量のファイル一覧でカーソルを使った反復処理を行っていません。
防止策: cursor が未定義になるまでループ内でカーソルベースのページネーションを使用してください。
問題 #7: 削除が失敗しても通知されない
エラー: ファイルが削除されず、ストレージクォータがいっぱいになる
出典: https://github.com/vercel/storage/issues/150
発生理由: URL形式が誤っているため、Blobが見つかりません。
防止策: put() のレスポンスから完全なBlob URLを使用し、削除結果を確認してください。
問題 #8: アップロードのタイムアウト(大きなファイル)+サーバー側の4.5MB制限
エラー: 100MBを超えるファイルで Error: Request timeout が発生する(サーバー側)、または4.5MBでファイルのアップロードが失敗する(サーバーレス関数の制限)
出典: Vercel function timeout limits + 4.5MB serverless limit + Community Discussion
発生理由:
- サーバー側アップロードでは、サーバーレス関数のタイムアウトが発生します(無料プランは10秒、Proは60秒)。
- 重要: Vercelのサーバーレス関数には、リクエストボディのハードリミットが4.5MBあります。サーバーアクション/APIルートで
put()を使用すると、4.5MBを超えるファイルのアップロードは失敗します。
防止策: 4.5MBを超えるファイルには、クライアント側アップロードで handleUpload() を使用するか、マルチパートアップロードを使用してください。
// ❌ Server-side upload fails at 4.5MB
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File; // Fails if >4.5MB
await put(file.name, file, { access: 'public' });
}
// ✅ Client upload bypasses 4.5MB limit (supports up to 500MB)
const blob = await upload(file.name, file, {
access: 'public',
handleUploadUrl: '/api/upload/token',
multipart: true, // For files >500MB, use multipart
});
Issue #9:ファイル名の衝突
エラー:ファイルが上書きされ、データが失われます
出典:本番環境でのデバッグ
発生理由:複数のアップロードで同じファイル名を使用しています
防止策:タイムスタンプ/UUIDを追加します:`uploads/${Date.now()}-${file.name}` または addRandomSuffix: true を使用します。
Issue #10:アップロードコールバックがない
エラー:アップロードは完了しますが、アプリの状態が更新されません
出典:https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload
発生理由:onUploadCompleted コールバックを実装していません
防止策:handleUpload() で onUploadCompleted を使用し、データベース/状態を更新します。
Issue #11:大容量ファイルのクライアントアップロードトークンの有効期限切れ
エラー:Error: Access denied, please provide a valid token for this resource
出典:GitHub Issue #443
発生理由:デフォルトのトークンは30秒で有効期限が切れます。大容量ファイル(>100MB)はアップロードに時間がかかるため、検証前にトークンの有効期限が切れます。
防止策:大容量ファイルをアップロードする場合は、validUntil パラメーターを設定します。
// For large files (>100MB), extend token expiration
const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
return {
maximumSizeInBytes: 200 * 1024 * 1024,
validUntil: Date.now() + 300000, // 5 minutes
};
},
});
Issue #12:v2.0.0の破壊的変更 - onUploadCompletedにはcallbackUrlが必要(Vercel以外でのホスティング)
エラー:Vercelでホストしていない場合、onUploadCompletedコールバックが実行されません
出典:Release Notes @vercel/[email protected]
発生理由:セキュリティ上の理由から、v2.0.0ではクライアント側の location.href からコールバックURLを自動推測する機能が削除されました。Vercelのシステム環境変数を使用しない場合は、callbackUrl を明示的に指定する必要があります。
防止策:Vercel以外でホストする場合は、onBeforeGenerateToken で callbackUrl を明示的に指定します。
// v2.0.0+ for non-Vercel hosting
await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
return {
callbackUrl: 'https://example.com', // Required for non-Vercel hosting
};
},
onUploadCompleted: async ({ blob, tokenPayload }) => {
// Now fires correctly
},
});
// For local development with ngrok:
// VERCEL_BLOB_CALLBACK_URL=https://abc123.ngrok-free.app
Issue #13:FirefoxではReadableStreamのアップロードがサポートされていない
エラー:Firefoxでアップロードが完了しません
出典:GitHub Issue #881
発生理由:TypeScriptインターフェースではボディの型として ReadableStream を受け付けますが、Firefoxはfetchのボディとして ReadableStream をサポートしていません。
防止策:クロスブラウザーに対応するには、ストリームをBlobまたはArrayBufferに変換します。
// ❌ Works in Chrome/Edge, hangs in Firefox
const stream = new ReadableStream({ /* ... */ });
await put('file.bin', stream, { access: 'public' }); // Never completes in Firefox
// ✅ Convert stream to Blob for cross-browser support
const chunks: Uint8Array[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const blob = new Blob(chunks);
await put('file.bin', blob, { access: 'public' });
Issue #14:onBeforeGenerateTokenではpathnameを変更できない
エラー:サーバー側でpathnameを上書きしようとしても、ファイルが誤ったパスにアップロードされます
出典:GitHub Issue #863
発生理由:onBeforeGenerateToken の pathname パラメーターは変更できません。クライアント側で upload(pathname, ...) を呼び出した時点で設定されます。
防止策:クライアント側でpathnameを構築し、サーバー側で検証します。メタデータの受け渡しには clientPayload を使用します。
// Client: Construct pathname before upload
await upload(`uploads/${Date.now()}-${file.name}`, file, {
access: 'public',
handleUploadUrl: '/api/upload',
clientPayload: JSON.stringify({ userId: '123' }),
});
// Server: Validate pathname matches expected pattern
await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname, clientPayload) => {
const { userId } = JSON.parse(clientPayload || '{}');
// Validate pathname starts with expected prefix
if (!pathname.startsWith(`uploads/`)) {
throw new Error('Invalid upload path');
}
return {
allowedContentTypes: ['image/jpeg', 'image/png'],
tokenPayload: JSON.stringify({ userId }), // Pass to onUploadCompleted
};
},
});
Issue #15:マルチパートアップロードの最小チャンクサイズ(5MB)
エラー:小さいチャンクを使用した手動マルチパートアップロードが失敗します
出典:公式ドキュメント + コミュニティディスカッション
発生理由:手動マルチパートアップロードでは、各パートは5MB以上である必要があります(最後のパートを除く)。これはVercelのサーバーレス関数の4.5MB制限と競合するため、サーバー側のルート経由では手動マルチパートアップロードを実行できません。
防止策:自動マルチパート(put() で multipart: true を指定)またはクライアントアップロードを使用します。
// ❌ Manual multipart upload fails (can't upload 5MB chunks via serverless function)
const upload = await createMultipartUpload('large.mp4', { access: 'public' });
// uploadPart() requires 5MB minimum - hits serverless limit
// ✅ Use automatic multipart via client upload
await upload('large.mp4', file, {
access: 'public',
handleUploadUrl: '/api/upload',
multipart: true, // Automatically handles 5MB+ chunks
});
Issue #16:ファイル拡張子がないとアクセス拒否エラーが発生する
エラー:Error: Access denied, please provide a valid token for this resource
出典:GitHub Issue #664
発生理由:ファイル拡張子のないパス名を指定すると、詳細の不明なアクセス拒否エラーが発生します。
防止策:パス名には必ずファイル拡張子を含めます。
// ❌ Fails with confusing error
await upload('user-12345', file, {
access: 'public',
handleUploadUrl: '/api/upload',
}); // Error: Access denied
// ✅ Extract extension and include in pathname
const extension = file.name.split('.').pop();
await upload(`user-${userId}.${extension}`, file, {
access: 'public',
handleUploadUrl: '/api/upload',
});
よくあるパターン
アバターの置き換えアップロード:
'use server';
import { put, del } from '@vercel/blob';
export async function updateAvatar(userId: string, formData: FormData) {
const file = formData.get('avatar') as File;
if (!file.type.startsWith('image/')) throw new Error('Only images allowed');
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (user?.avatarUrl) await del(user.avatarUrl); // Delete old
const blob = await put(`avatars/${userId}.jpg`, file, { access: 'public' });
await db.update(users).set({ avatarUrl: blob.url }).where(eq(users.id, userId));
return blob.url;
}
保護されたアップロード(access: 'private'):
const blob = await put(`documents/${userId}/${file.name}`, file, { access: 'private' });