关于 vercel-blob
面向使用 Vercel Blob 的开发者,提供文件存储、上传和管理的实现指南。重点说明客户端安全上传、分片上传、文件管理及常见问题防范。
主要功能
- 01服务端上传文件
- 02安全的客户端上传令牌
- 03列出与删除 Blob
- 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 函数超时限制 + 4.5MB 无服务器限制 + 社区讨论
原因:
- 服务端上传受无服务器函数超时限制(免费套餐 10 秒,专业套餐 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
});
问题 #9:文件名冲突
错误:文件被覆盖,数据丢失
来源:生产环境调试
发生原因:多个上传使用同一文件名
预防措施:添加时间戳/UUID:`uploads/${Date.now()}-${file.name}` 或设置 addRandomSuffix: true。
问题 #10:缺少上传回调
错误:上传完成,但应用状态未更新
来源:https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload
发生原因:未实现 onUploadCompleted 回调
预防措施:在 handleUpload() 中使用 onUploadCompleted 更新数据库/状态。
问题 #11:大文件的客户端上传令牌过期
错误:Error: Access denied, please provide a valid token for this resource
来源:GitHub 问题 #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
};
},
});
问题 #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
问题 #13:Firefox 不支持 ReadableStream 上传
错误:在 Firefox 中上传始终无法完成
来源:GitHub 问题 #881
发生原因:TypeScript 接口接受 ReadableStream 作为 body 类型,但 Firefox 不支持将 ReadableStream 用作 fetch body。
预防措施:将流转换为 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' });
问题 #14:无法在 onBeforeGenerateToken 中修改 pathname
错误:尽管尝试在服务端覆盖 pathname,文件仍上传到了错误路径
来源:GitHub 问题 #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
};
},
});
问题 #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
});
问题 #16:缺少文件扩展名会导致访问被拒绝错误
错误:Error: Access denied, please provide a valid token for this resource
来源:GitHub 问题 #664
发生原因:不带文件扩展名的 pathname 会导致信息不明确的访问被拒绝错误。
预防措施:始终在 pathname 中包含文件扩展名。
// ❌ 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' });