vercel-blob

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Vercel Blob

Vercel Blob

Last Updated: 2026-01-21 Version: @vercel/blob@2.0.0 Skill Version: 2.1.0

最后更新时间: 2026-01-21 版本: @vercel/blob@2.0.0 技能版本: 2.1.0

Quick Start

快速开始

bash
undefined
bash
undefined

Create Blob store: Vercel Dashboard → Storage → Blob

创建Blob存储:Vercel控制台 → 存储 → Blob

vercel env pull .env.local # Creates BLOB_READ_WRITE_TOKEN npm install @vercel/blob

**Server Upload**:
```typescript
'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;
}
CRITICAL: Never expose
BLOB_READ_WRITE_TOKEN
to client. Use
handleUpload()
for client uploads.

vercel env pull .env.local # 生成BLOB_READ_WRITE_TOKEN npm install @vercel/blob

**服务器端上传**:
```typescript
'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()

Client Upload (Secure)

安全客户端上传

Server Action (generates presigned token):
typescript
'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
    })
  });
}
Client Component:
typescript
'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
});

服务器操作(生成预签名令牌):
typescript
'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
    })
  });
}
客户端组件:
typescript
'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
});

File Management

文件管理

List/Delete:
typescript
import { list, del } from '@vercel/blob';

// List with pagination
const { blobs, cursor } = await list({ prefix: 'uploads/', cursor });

// Delete
await del(blobUrl);
Multipart (>500MB):
typescript
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 });

列出/删除:
typescript
import { list, del } from '@vercel/blob';

// 带分页的列表查询
const { blobs, cursor } = await list({ prefix: 'uploads/', cursor });

// 删除文件
await del(blobUrl);
分块上传(>500MB):
typescript
import { createMultipartUpload, uploadPart, completeMultipartUpload } from '@vercel/blob';

const upload = await createMultipartUpload('large-video.mp4', { access: 'public' });
// 循环上传分片...
await completeMultipartUpload({ uploadId: upload.uploadId, parts });

Critical Rules

关键规则

Always:
  • ✅ Use
    handleUpload()
    for client uploads (never expose
    BLOB_READ_WRITE_TOKEN
    )
  • ✅ Validate file type/size before upload
  • ✅ Use pathname organization (
    avatars/
    ,
    uploads/
    )
  • ✅ Add timestamp/UUID to filenames (avoid collisions)
Never:
  • ❌ Expose
    BLOB_READ_WRITE_TOKEN
    to client
  • ❌ Upload >500MB without multipart
  • ❌ Skip file validation

必须遵守:
  • ✅ 客户端上传使用
    handleUpload()
    (切勿暴露
    BLOB_READ_WRITE_TOKEN
  • ✅ 上传前验证文件类型/大小
  • ✅ 使用路径名组织文件(如
    avatars/
    uploads/
  • ✅ 为文件名添加时间戳/UUID(避免冲突)
严禁操作:
  • ❌ 向客户端暴露
    BLOB_READ_WRITE_TOKEN
  • ❌ 上传超过500MB的文件时不使用分块上传
  • ❌ 跳过文件验证

Known Issues Prevention

已知问题预防

This skill prevents 16 documented issues:
本技能可预防16种已记录的问题:

Issue #1: Missing Environment Variable

问题1:环境变量缺失

Error:
Error: BLOB_READ_WRITE_TOKEN is not defined
Source: https://vercel.com/docs/storage/vercel-blob Why It Happens: Token not set in environment Prevention: Run
vercel env pull .env.local
and ensure
.env.local
in
.gitignore
.
错误信息:
Error: BLOB_READ_WRITE_TOKEN is not defined
来源: https://vercel.com/docs/storage/vercel-blob 原因: 未在环境中设置令牌 预防措施: 执行
vercel env pull .env.local
并确保
.env.local
已添加到
.gitignore
中。

Issue #2: Client Upload Token Exposed

问题2:客户端上传令牌暴露

Error: Security vulnerability, unauthorized uploads Source: https://vercel.com/docs/storage/vercel-blob/client-upload Why It Happens: Using
BLOB_READ_WRITE_TOKEN
directly in client code Prevention: Use
handleUpload()
to generate client-specific tokens with constraints.
错误信息: 安全漏洞,未授权上传 来源: https://vercel.com/docs/storage/vercel-blob/client-upload 原因: 在客户端代码中直接使用
BLOB_READ_WRITE_TOKEN
预防措施: 使用
handleUpload()
生成带约束的客户端专属令牌。

Issue #3: File Size Limit Exceeded

问题3:文件大小超限

Error:
Error: File size exceeds limit
(500MB) Source: https://vercel.com/docs/storage/vercel-blob/limits Why It Happens: Uploading file >500MB without multipart upload Prevention: Validate file size before upload, use multipart upload for large files.
错误信息:
Error: File size exceeds limit
(500MB) 来源: https://vercel.com/docs/storage/vercel-blob/limits 原因: 上传超过500MB的文件时未使用分块上传 预防措施: 上传前验证文件大小,大文件使用分块上传。

Issue #4: Wrong Content-Type

问题4:Content-Type错误

Error: Browser downloads file instead of displaying (e.g., PDF opens as text) Source: Production debugging Why It Happens: Not setting
contentType
option, Blob guesses incorrectly Prevention: Always set
contentType: file.type
or explicit MIME type.
错误信息: 浏览器下载文件而非直接显示(如PDF以文本形式打开) 来源: 生产环境调试 原因: 未设置
contentType
选项,Blob自动猜测错误 预防措施: 始终设置
contentType: file.type
或明确的MIME类型。

Issue #5: Public File Not Cached

问题5:公开文件未被缓存

Error: Slow file delivery, high egress costs Source: Vercel Blob best practices Why It Happens: Using
access: 'private'
for files that should be public Prevention: Use
access: 'public'
for publicly accessible files (CDN caching).
错误信息: 文件分发缓慢,出口流量成本高 来源: Vercel Blob最佳实践 原因: 对应公开的文件使用了
access: 'private'
预防措施: 对公开可访问的文件使用
access: 'public'
(支持CDN缓存)。

Issue #6: List Pagination Not Handled

问题6:未处理列表分页

Error: Only first 1000 files returned, missing files Source: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#list Why It Happens: Not iterating with cursor for large file lists Prevention: Use cursor-based pagination in loop until
cursor
is undefined.
错误信息: 仅返回前1000个文件,存在文件缺失 来源: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#list 原因: 未对大文件列表使用游标进行迭代 预防措施: 使用基于游标的分页循环查询,直到
cursor
为undefined。

Issue #7: Delete Fails Silently

问题7:删除操作静默失败

Error: Files not deleted, storage quota fills up Source: https://github.com/vercel/storage/issues/150 Why It Happens: Using wrong URL format, blob not found Prevention: Use full blob URL from
put()
response, check deletion result.
错误信息: 文件未被删除,存储配额被占满 来源: https://github.com/vercel/storage/issues/150 原因: 使用错误的URL格式,未找到Blob文件 预防措施: 使用
put()
返回的完整Blob URL,并检查删除结果。

Issue #8: Upload Timeout (Large Files) + Server-Side 4.5MB Limit

问题8:上传超时(大文件)+ 服务器端4.5MB限制

Error:
Error: Request timeout
for files >100MB (server) OR file upload fails at 4.5MB (serverless function limit) Source: Vercel function timeout limits + 4.5MB serverless limit + Community Discussion Why It Happens:
  • Serverless function timeout (10s free tier, 60s pro) for server-side uploads
  • CRITICAL: Vercel serverless functions have a hard 4.5MB request body limit. Using
    put()
    in server actions/API routes fails for files >4.5MB.
Prevention: Use client-side upload with
handleUpload()
for files >4.5MB OR use multipart upload.
typescript
// ❌ 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
});
错误信息: 服务器端上传大文件(>100MB)时出现
Error: Request timeout
,或文件上传在4.5MB处失败(无服务器函数限制) 来源: Vercel函数超时限制 + 4.5MB无服务器限制 + 社区讨论 原因:
  • 服务器端上传时无服务器函数超时(免费层10秒,专业版60秒)
  • 重要提示: Vercel无服务器函数存在4.5MB的请求体硬限制。在服务器操作/API路由中使用
    put()
    上传超过4.5MB的文件会失败。
预防措施: 对于超过4.5MB的文件,使用
handleUpload()
进行客户端上传,或使用分块上传。
typescript
// ❌ 服务器端上传在4.5MB处失败
export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File; // 文件超过4.5MB时失败
  await put(file.name, file, { access: 'public' });
}

// ✅ 客户端上传绕过4.5MB限制(支持最大500MB)
const blob = await upload(file.name, file, {
  access: 'public',
  handleUploadUrl: '/api/upload/token',
  multipart: true, // 超过500MB时使用分块上传
});

Issue #9: Filename Collisions

问题9:文件名冲突

Error: Files overwritten, data loss Source: Production debugging Why It Happens: Using same filename for multiple uploads Prevention: Add timestamp/UUID:
`uploads/${Date.now()}-${file.name}`
or
addRandomSuffix: true
.
错误信息: 文件被覆盖,数据丢失 来源: 生产环境调试 原因: 多个上传使用相同文件名 预防措施: 添加时间戳/UUID:
`uploads/${Date.now()}-${file.name}`
或使用
addRandomSuffix: true

Issue #10: Missing Upload Callback

问题10:缺少上传回调

Error: Upload completes but app state not updated Source: https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload Why It Happens: Not implementing
onUploadCompleted
callback Prevention: Use
onUploadCompleted
in
handleUpload()
to update database/state.
错误信息: 上传完成但应用状态未更新 来源: https://vercel.com/docs/storage/vercel-blob/client-upload#callback-after-upload 原因: 未实现
onUploadCompleted
回调 预防措施: 在
handleUpload()
中使用
onUploadCompleted
来更新数据库/状态。

Issue #11: Client Upload Token Expiration for Large Files

问题11:大文件上传时客户端令牌过期

Error:
Error: Access denied, please provide a valid token for this resource
Source: GitHub Issue #443 Why It Happens: Default token expires after 30 seconds. Large files (>100MB) take longer to upload, causing token expiration before validation. Prevention: Set
validUntil
parameter for large file uploads.
typescript
// 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
    };
  },
});
错误信息:
Error: Access denied, please provide a valid token for this resource
来源: GitHub Issue #443 原因: 默认令牌30秒后过期。大文件(>100MB)上传耗时超过30秒,导致验证前令牌过期。 预防措施: 为大文件上传设置
validUntil
参数。
typescript
// 大文件(>100MB)上传时延长令牌有效期
const jsonResponse = await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname) => {
    return {
      maximumSizeInBytes: 200 * 1024 * 1024,
      validUntil: Date.now() + 300000, // 5分钟
    };
  },
});

Issue #12: v2.0.0 Breaking Change - onUploadCompleted Requires callbackUrl (Non-Vercel Hosting)

问题12:v2.0.0破坏性变更 - 非Vercel托管时onUploadCompleted需要callbackUrl

Error: onUploadCompleted callback doesn't fire when not hosted on Vercel Source: Release Notes @vercel/blob@2.0.0 Why It Happens: v2.0.0 removed automatic callback URL inference from client-side
location.href
for security. When not using Vercel system environment variables, you must explicitly provide
callbackUrl
. Prevention: Explicitly provide
callbackUrl
in
onBeforeGenerateToken
for non-Vercel hosting.
typescript
// 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
错误信息: 未托管在Vercel上时onUploadCompleted回调不触发 来源: Release Notes @vercel/blob@2.0.0 原因: v2.0.0为了安全,移除了从客户端
location.href
自动推断回调URL的功能。当不使用Vercel系统环境变量时,必须显式提供
callbackUrl
预防措施: 对于非Vercel托管环境,在
onBeforeGenerateToken
中显式提供
callbackUrl
typescript
// v2.0.0+ 非Vercel托管环境使用
await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname) => {
    return {
      callbackUrl: 'https://example.com', // 非Vercel托管时必填
    };
  },
  onUploadCompleted: async ({ blob, tokenPayload }) => {
    // 现在可正常触发
  },
});

// ngrok本地开发时:
// VERCEL_BLOB_CALLBACK_URL=https://abc123.ngrok-free.app

Issue #13: ReadableStream Upload Not Supported in Firefox

问题13:Firefox不支持ReadableStream上传

Error: Upload never completes in Firefox Source: GitHub Issue #881 Why It Happens: The TypeScript interface accepts
ReadableStream
as a body type, but Firefox does not support
ReadableStream
as a fetch body. Prevention: Convert stream to Blob or ArrayBuffer for cross-browser support.
typescript
// ❌ 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' });
错误信息: Firefox中上传始终无法完成 来源: GitHub Issue #881 原因: TypeScript接口接受
ReadableStream
作为主体类型,但Firefox不支持将
ReadableStream
作为fetch主体。 预防措施: 将流转换为Blob或ArrayBuffer以实现跨浏览器支持。
typescript
// ❌ 在Chrome/Edge中可用,在Firefox中挂起
const stream = new ReadableStream({ /* ... */ });
await put('file.bin', stream, { access: 'public' }); // 在Firefox中始终无法完成

// ✅ 将流转换为Blob以实现跨浏览器支持
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: Pathname Cannot Be Modified in onBeforeGenerateToken

问题14:onBeforeGenerateToken中无法修改Pathname

Error: File uploaded to wrong path despite server-side pathname override attempt Source: GitHub Issue #863 Why It Happens: The
pathname
parameter in
onBeforeGenerateToken
cannot be changed. It's set at
upload(pathname, ...)
time on the client side. Prevention: Construct pathname on client, validate on server. Use
clientPayload
to pass metadata.
typescript
// 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
    };
  },
});
错误信息: 尽管尝试在服务器端覆盖路径名,文件仍上传到错误路径 来源: GitHub Issue #863 原因:
onBeforeGenerateToken
中的
pathname
参数无法修改,它是在客户端
upload(pathname, ...)
时设置的。 预防措施: 在客户端构造路径名,在服务器端验证。使用
clientPayload
传递元数据。
typescript
// 客户端:上传前构造路径名
await upload(`uploads/${Date.now()}-${file.name}`, file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
  clientPayload: JSON.stringify({ userId: '123' }),
});

// 服务器端:验证路径名是否符合预期格式
await handleUpload({
  body,
  request,
  onBeforeGenerateToken: async (pathname, clientPayload) => {
    const { userId } = JSON.parse(clientPayload || '{}');

    // 验证路径名是否以预期前缀开头
    if (!pathname.startsWith(`uploads/`)) {
      throw new Error('无效上传路径');
    }

    return {
      allowedContentTypes: ['image/jpeg', 'image/png'],
      tokenPayload: JSON.stringify({ userId }), // 传递给onUploadCompleted
    };
  },
});

Issue #15: Multipart Upload Minimum Chunk Size (5MB)

问题15:分块上传最小分片大小(5MB)

Error: Manual multipart upload fails with small chunks Source: Official Docs + Community Discussion Why It Happens: Each part in manual multipart upload must be at least 5MB (except the last part). This conflicts with Vercel's 4.5MB serverless function limit, making manual multipart uploads impossible via server-side routes. Prevention: Use automatic multipart (
multipart: true
in
put()
) or client uploads.
typescript
// ❌ 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
});
错误信息: 手动分块上传在使用小分片时失败 来源: 官方文档 + 社区讨论 原因: 手动分块上传的每个分片至少为5MB(最后一个分片除外)。这与Vercel的4.5MB无服务器函数限制冲突,导致无法通过服务器端路由进行手动分块上传。 预防措施: 使用自动分块上传(
put()
中设置
multipart: true
)或客户端上传。
typescript
// ❌ 手动分块上传失败(无法通过无服务器函数上传5MB分片)
const upload = await createMultipartUpload('large.mp4', { access: 'public' });
// uploadPart()要求最小5MB - 触发无服务器限制

// ✅ 通过客户端上传使用自动分块
await upload('large.mp4', file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
  multipart: true, // 自动处理5MB+分片
});

Issue #16: Missing File Extension Causes Access Denied Error

问题16:缺少文件扩展名导致访问被拒绝错误

Error:
Error: Access denied, please provide a valid token for this resource
Source: GitHub Issue #664 Why It Happens: Pathname without file extension causes non-descriptive access denied error. Prevention: Always include file extension in pathname.
typescript
// ❌ 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',
});

错误信息:
Error: Access denied, please provide a valid token for this resource
来源: GitHub Issue #664 原因: 路径名中缺少文件扩展名导致模糊的访问被拒绝错误。 预防措施: 路径名中始终包含文件扩展名。
typescript
// ❌ 触发模糊错误
await upload('user-12345', file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
}); // 错误:Access denied

// ✅ 提取扩展名并包含在路径名中
const extension = file.name.split('.').pop();
await upload(`user-${userId}.${extension}`, file, {
  access: 'public',
  handleUploadUrl: '/api/upload',
});

Common Patterns

常见模式

Avatar Upload with Replacement:
typescript
'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;
}
Protected Upload (
access: 'private'
):
typescript
const blob = await put(`documents/${userId}/${file.name}`, file, { access: 'private' });
头像上传与替换:
typescript
'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('仅允许上传图片');

  const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
  if (user?.avatarUrl) await del(user.avatarUrl); // 删除旧头像

  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'
):
typescript
const blob = await put(`documents/${userId}/${file.name}`, file, { access: 'private' });