# How to upload and store files with Vercel

**Author:** Vercel

---

## Key takeaways

- Every file upload on Vercel reduces to one decision: route the file through a function or send it straight to storage. Vercel Functions cap the request body at 4.5 MB, and anything larger returns a 413.
  
- Client-direct upload using `upload()` and `handleUpload()` from `@vercel/blob/client` is the default for user-generated files. The file never touches the function and incurs no data transfer charge.
  
- Vercel Blob removes the integration work of hand-assembled S3, where region mismatches surface as Cross-Origin Resource Sharing (CORS) errors, presigned URLs can't be individually revoked, and multipart uploads fail without logging.
  
- Treating every blob as immutable with `addRandomSuffix: true` removes cache invalidation as a category of work. Each upload gets a unique URL, and the CDN handles ETag and 304 responses.
  
- Blob runs on S3's 99.999999999% durability, with delivery 3x more cost-efficient than Fast Data Transfer on average.
  

A file upload feature often looks finished after an afternoon of work. You wire up a form, point it at storage, and test it with a 200 KB avatar. Then a user uploads a 12 MB PDF and the request returns a 413. The root cause sits in the architecture underneath the upload code, not in the upload code itself.

This guide walks through uploading and storing files on Vercel with Vercel Blob, which stores over 400 million files in production. You need three things to follow along:

- A Blob store provisioned from the dashboard or CLI, with its access mode (public or private) selected at creation
  
- The `@vercel/blob` SDK installed
  
- Authentication, which Vercel handles for you: code running on Vercel authenticates with a short-lived OIDC (OpenID Connect) token by default, and the `BLOB_READ_WRITE_TOKEN` environment variable covers code running outside Vercel and client token generation
  

There is no bucket to name, no IAM (Identity and Access Management) policy to write, and no CORS file to maintain.

The rest of this guide covers the one decision that determines every upload architecture on Vercel, the failure modes that decision protects you from, and the patterns that hold up under production traffic.

## Route files around the 4.5 MB function limit

Every file upload architecture on Vercel comes down to a single question. Does the file pass through your serverless function, or does it go straight to storage? Answer that, and every other choice follows.

The question matters because it sets a hard limit. Vercel Functions cap the request body at 4.5 MB, and that ceiling applies to both Server Actions and Route Handlers. Route any file larger than approximately 4.5 MB through a function and you get a 413. No configuration flag changes that number, because the constraint lives at the infrastructure level. This is why the avatar works and the PDF doesn't. The fix is to stop sending the file through the function at all.

Make client-direct upload your default, and treat routing a file through the function as the exception you justify. In the client-direct pattern, the browser sends the file straight to Vercel Blob, and your server only handles a short-lived token exchange. The `upload()` and `handleUpload()` methods from `@vercel/blob/client` make the 4.5 MB boundary irrelevant, because the file never touches the function while authentication still happens server-side through a scoped token.

Server Actions still earn their place in a narrow role. They fit small, known-size assets like an avatar image, where the file is comfortably under the limit and a single server function buys you more than the headroom costs. Don't treat that convenience as the default. The moment your app accepts user-generated files of unknown size, the Server Action path becomes a production incident waiting for its first large upload. Pick the pattern from the file's origin and size ceiling.

## Skip the CORS, presigned URL, and multipart setup

The conventional way to build this without Blob is to stitch together S3, a CDN, presigned URLs, and a CORS policy. Every piece works in isolation. The trouble starts at the seams, where the bugs live in the interaction between components. Three failures consume the most engineering time.

### CORS errors that aren't CORS errors

A developer configures the bucket's CORS policy correctly, the presigned upload still fails with what looks like a cross-origin error, and hours disappear into a config that was already correct. The real cause is usually a region mismatch, where the region used to sign the URL doesn't match the bucket's region. Credential environment variables and hyphenated bucket names produce the same misleading signature failures. Blob removes this surface. The SDK connects through the platform integration, which leaves no bucket to configure, no CORS policy to write, and no region to keep in sync.

### Presigned URLs you can't revoke

A presigned URL behaves like a bearer token. Anyone holding it can use its baked-in permissions, and you can't revoke a single URL after generating it. Your only lever is rotating the credentials that signed it, which invalidates every other URL signed with those same credentials at once. Blob's client flow issues a short-lived token through `onBeforeGenerateToken`, scoped to one upload and valid for an hour by default, configurable through `validUntil`. Even if you skip the auth check, the SDK returns a scoped token instead of exposing store credentials.

### Multipart uploads that fail silently

Raw S3 multipart can fail with no exception thrown and nothing logged, while S3 retains every uploaded part until the upload is explicitly completed or aborted. Without lifecycle policies to sweep up abandoned uploads, storage cost grows where nobody is looking. Pass `multipart: true` to `put()` or `upload()` and Blob splits the file, uploads parts in parallel, retries failed parts, and reassembles automatically, up to a 5 TB file with built-in resumability. It also retries streams, including Node.js streams and the Web Streams API.

Each of these is manageable on its own. Combined, they're the reason a file-upload feature turns into a multi-week integration project. The pattern to notice: each one is less about uploading a file and more about owning an integration surface by hand.

## Pick your upload pattern by file size and origin

There are three ways to put a file into Vercel Blob, and the choice becomes mechanical once you know the file's size and where it originates. Match your situation to a pattern and skip to the one you need.

| Dimension                   | Server Action                       | Client-direct upload                          | Multipart                       |
| --------------------------- | ----------------------------------- | --------------------------------------------- | ------------------------------- |
| File size range             | Under 4.5 MB                        | Any size up to 5 TB                           | Over 100 MB, up to 5 TB         |
| Where the upload originates | Server function                     | Browser, straight to storage                  | Browser or server               |
| Data transfer cost          | Counts against Fast Data Transfer   | No data transfer charge                       | Follows the originating pattern |
| Where validation happens    | Inside the function, before storage | `allowedContentTypes` and `onUploadCompleted` | Same as the originating pattern |

The table holds the whole argument. Server Actions are the small-file convenience case, client-direct is the default for anything user-generated, and multipart is a flag you add to either when files get large. Each pattern below is production-ready code.

### Server Actions for small, known-size files

For files comfortably under 4.5 MB, a Server Action works with no client-side JavaScript to maintain:

`import { put } from '@vercel/blob'; import { revalidatePath } from 'next/cache'; export async function Form() { async function uploadImage(formData: FormData) { 'use server'; const imageFile = formData.get('image') as File; const blob = await put(imageFile.name, imageFile, { access: 'public', addRandomSuffix: true, }); revalidatePath('/'); return blob; } return ( <form action={uploadImage}> <input type="file" name="image" required /> <button>Upload</button> </form> ); }`

The `addRandomSuffix: true` option does more work than it looks. Treat blobs as immutable objects. CDN cache propagation completes within 60 seconds, which means overwriting a file at the same path leaves stale versions sitting in browser caches. A random suffix gives every upload a unique URL, and the cache invalidation problem disappears because no URL is ever reused. The cost is small but real: you can't predict the final URL before the upload returns, so store what comes back instead of constructing the path yourself.

### Client-direct upload for everything else

This is the pattern most applications should default to. The file moves from the browser straight to Vercel Blob, your server handles authentication, and your server gets a callback when the upload finishes.

The server endpoint:

`import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; export async function POST(request: Request): Promise<NextResponse> { const body = (await request.json()) as HandleUploadBody; try { const jsonResponse = await handleUpload({ body, request, onBeforeGenerateToken: async (pathname) => { const session = await auth(); if (!session) throw new Error('Not authenticated'); return { allowedContentTypes: ['image/jpeg', 'image/png', 'image/webp'], addRandomSuffix: true, tokenPayload: JSON.stringify({ userId: session.user.id }), }; }, onUploadCompleted: async ({ blob, tokenPayload }) => { const { userId } = JSON.parse(tokenPayload); // Update the database with blob.url }, }); return NextResponse.json(jsonResponse); } catch (error) { return NextResponse.json( { error: (error as Error).message }, { status: 400 }, ); } }` The client component: `'use client'; import { upload } from '@vercel/blob/client'; import { useState } from 'react'; export default function AvatarUploadPage() { const [blobUrl, setBlobUrl] = useState<string | null>(null); return ( <> <input type="file" onChange={async (e) => { const file = e.target.files?.[0]; if (!file) return; const newBlob = await upload(file.name, file, { access: 'public', handleUploadUrl: '/api/avatar/upload', }); setBlobUrl(newBlob.url); }} /> {blobUrl && <img src={blobUrl} />} </> ); }` This pattern is cheaper by design. Client uploads incur no data transfer charge because the file never passes through Vercel Functions, while server uploads count against Fast Data Transfer the moment the function receives the bytes. At production upload volume, that difference compounds month over month. The tradeoff is that the function never sees the file, so you can't inspect its contents before it lands in storage. Validation moves to three places, all visible in the endpoint above. You constrain content types through `allowedContentTypes` (which accepts MIME types such as `image/jpeg`), enforce size through content-length limits, and run post-upload checks inside `onUploadCompleted`. If your app needs to read file contents server-side before accepting them, that's the one case where routing through a function is the justified exception. ### Large files with multipart and progress tracking For files over 100 MB, use multipart. The SDK handles chunking and reassembly, and adding `onUploadProgress` gives you user-facing feedback without extra plumbing: ``import { put } from '@vercel/blob'; const blob = await put(filename, file, { access: 'public', multipart: true, onUploadProgress: ({ loaded, total, percentage }) => { console.log(`${percentage}% uploaded (${loaded}/${total} bytes)`); }, });`` The progress callback fires as each part completes, so the percentage stays accurate all the way to the 5 TB ceiling. The same callback works identically with the client-side `upload()` method, which means a large user-facing upload gets both the no-transfer-charge benefit of client-direct and a real progress bar from the same code path. ### Confirm the upload worked A successful upload returns a blob object, and the only field you need is `blob.url`. That URL is live immediately. Once a file is uploaded, it's available and cached across the delivery network without any propagation wait on your side. Storing `blob.url` in your database and rendering it is the whole confirmation path for most apps. You skip polling for readiness and any "wait for the CDN" step, because the URL the SDK hands back already resolves. The signal that caching works correctly comes from the response headers. When a browser requests a public blob URL, the CDN includes an ETag, a response header that identifies the version of the content. On the next request, the browser sends `If-None-Match`, and if the blob hasn't changed, the CDN responds with 304 Not Modified instead of resending the bytes. Seeing that 304 on a repeat request confirms the CDN is serving cached content. If you're debugging a stale-content report, inspect that header exchange first. Because every upload produces a unique URL, a genuine stale-cache bug is almost always a reused path, not a CDN fault. ## Treat every blob as immutable Most file-storage code carries a mutable mental model: upload a file, update it in place when it changes, then invalidate the CDN cache. That model creates ongoing operational work, because someone has to track which files changed and where they're cached, and invalidations don't always take. Vercel Blob takes the opposite stance. Every blob is immutable. With `addRandomSuffix: true`, each upload produces a unique URL. To update a user's avatar, upload a new file, store the new URL, and delete the old blob. Deletes are free of charge. No URL is ever reused, so there's no cache to invalidate. The CDN handles the ETag and `If-None-Match` mechanics underneath, so repeat requests get a 304 and unchanged content never re-downloads. Two limits are relevant before you design around this: - Cache duration is controlled through cache headers, up to a maximum of one year.    - Blobs larger than 512 MB aren't cached. They generate a cache MISS on every access and are served from the origin.    That second limit is a deliberate tradeoff. Most production files, including avatars, product images, and documents, sit well under 512 MB and benefit from aggressive caching, while large video files still serve correctly without occupying the cache layer. If your workload is large-file-heavy, plan for origin serving. ## Lock down sensitive files with private storage Public URLs are correct for avatars, product images, and marketing assets, and they're wrong for contracts, invoices, and internal reports. A common mistake is a document store built on public blobs, with access "secured" by URL obscurity, where anyone who gets the URL gets the file. Until February 2026, public was the only option Blob offered, so plenty of apps inherited that exposure by default. Vercel Private Blob, now generally available on all plans, closes the gap. Files in a private store change the access model in three ways: - They require authentication for both writes and reads, so the URL alone grants nothing. Code running on Vercel authenticates with a short-lived, auto-rotating OIDC token scoped to the project.    - Read access routes through Vercel Functions, which puts your application in control of who sees each file and lets you layer in whatever authorization logic the data demands.    - The CDN still caches private blobs, up to a month by default, with the cache sitting behind the function. The function fetches the blob through the CDN and streams it to the browser.    Browser-side caching stays under your control through the `Cache-Control` header on the function's response. When you need to grant temporary access without putting your server in the data path (for example, letting a user download an invoice with an expiring link), Private Blob also supports signed URLs scoped to a single operation and pathname, valid for up to 7 days. One constraint lands at design time. You select a store's access mode, public or private, when you create it. If your app needs both public and private files, provision two separate stores from the start. Public blobs serve directly, and private blobs route their reads through the function. Deciding this up front costs you nothing. ## Build on S3 durability with Vercel's delivery network Two questions matter for a file store you're on call for: what happens to the bytes after the upload returns, and what it costs to serve them. The durability floor is S3. Vercel Blob is built on Amazon S3, which provides 99.999999999% durability (11 nines). At a billion stored objects, the expected loss is zero objects over 100 years, and availability is 99.99% in a given year, backed by automatic replication and error correction. You inherit that floor without managing any of it. The delivery layer sits on top of S3 and is tuned for a different job. Blob Data Transfer serves content through infrastructure optimized for high-volume assets, the below-the-fold content that isn't essential for First Contentful Paint (FCP) or Largest Contentful Paint (LCP). It runs 3x more cost-efficient than Fast Data Transfer on average because it doesn't pay for the latency guarantees critical-path traffic needs. The request flow: 1. A request hits the nearest Vercel region, which recognizes it as a Blob request and checks the cache.     2. On a cache hit, the response returns immediately.     3. On a cache miss, the asset is fetched from Blob storage, cached, and returned.     Pricing tracks S3 directly, with no Vercel premium on storage or operations: - Storage: $0.023 per GB-month    - Simple operations: $0.40 per million    - Advanced operations: $5.00 per million    - Blob Data Transfer: starting at $0.05 per GB, depending on region    One billing detail changes how you think about bursts. Storage is calculated from snapshots taken every 15 minutes and averaged across the month, so you're charged for average usage, not your peak. A burst of uploads you later delete bills for the time it sat there, not as though it occupied the month. The Pro plan includes 5 GB of storage, 100,000 simple operations, and 10,000 advanced operations each month. One integration removes a service most teams stand up separately. Blob URLs work directly with `next/image`, so Vercel Image Optimization resizes, converts to modern formats like WebP and AVIF, and compresses images served from Blob. You get optimized delivery without running a separate image pipeline. ## Replace your S3 integration stack with one SDK Look back across the failures in this guide. The 413 from the function body limit, the CORS errors that were really region mismatches, the presigned URLs you couldn't revoke, the multipart that failed without a sound, and the cache invalidations that didn't invalidate read like five different problems. They're one problem: the integration surface you take ownership of the moment you wire S3, a CDN, and your application together by hand. The time this feature consumes goes into the seams between components, not the components themselves. Vercel Blob collapses that surface into a single SDK. The `upload()` and `handleUpload()` methods authorize the browser to send files straight to storage, which sidesteps the function body limit, supports files to 5 TB with automatic multipart and progress, and keeps authentication server-side through scoped tokens. Immutable naming removes cache invalidation as a category of work. Private storage covers the sensitive files public URLs can't. All of it runs on S3 durability with Vercel's delivery network underneath. To build it, start from a [template](https://vercel.com/templates) or [deploy a new project](https://vercel.com/new).

## Frequently asked questions about Vercel file uploads

### What is the maximum file size for Vercel Blob uploads?

The maximum size for any individual upload is 5 TB. For files larger than 100 MB, use the `multipart: true` option, which splits the file into parts, uploads them in parallel, and reassembles them automatically with built-in retries and resumability.

### Why am I getting a 413 error when uploading files on Vercel?

Vercel Functions have a 4.5 MB request body limit. Any file routed through a Server Action or Route Handler above approximately 4.5 MB triggers a 413. Switch to the client-direct pattern using `upload()` from `@vercel/blob/client`, which sends the file straight to Blob storage and bypasses the function entirely.

### Does Vercel Blob work with frameworks other than Next.js?

Yes. The `@vercel/blob` SDK is a standard TypeScript package, and the `put()` and `upload()` methods work in any JavaScript runtime. The server-side token handler, `handleUpload()`, runs in any Vercel Function regardless of framework. Image Optimization for blob URLs works with Next.js, Astro, Nuxt, and other supported frameworks.

### How does Vercel Blob pricing compare to S3?

Storage and operation pricing matches S3: $0.023 per GB-month of storage, $0.40 per million simple operations, and $5.00 per million advanced operations. Blob Data Transfer runs 3x more cost-efficient than Fast Data Transfer on average thanks to Vercel's caching layer, and client uploads incur no data transfer charge at all. The Pro plan includes 5 GB of storage, 100,000 simple operations, and 10,000 advanced operations each month.

---

[View full KB sitemap](/kb/sitemap.md)
