# Vercel Blob tutorial: upload and serve files

> Upload files to Vercel Blob from a server or browser, validate uploads, choose public or private storage, serve files, and delete them safely.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-04 | Updated: 2026-08-03 | Topics: [Services](https://flaviocopes.com/tags/services/) | Canonical: https://flaviocopes.com/vercel-blob/

[Vercel Blob](https://vercel.com/docs/vercel-blob) is object storage for files.

Use it for images, documents, videos, exports, and other data that does not belong inside a database row or Git repository.

In this tutorial we'll upload an avatar, validate it, display it, and delete it. Then we'll see how private files change the design.

## Public and private Blob stores

You choose the access mode when creating a Blob store.

A **public** store returns URLs anyone can open. Use it for public images, downloads, and media.

A **private** store requires authentication for reads and writes. Use it for invoices, user documents, internal reports, and sensitive uploads.

You cannot change the access mode of an existing store. Choose before uploading data.

## Create a Blob store

Open your Vercel project and choose **Storage → Create Database → Blob**.

Create a public store for this avatar example and connect it to the project.

Vercel adds the store credentials to the project environment. Pull them locally:

```bash
npx vercel link
npx vercel env pull
```

Create a Next.js app if you do not already have one:

```bash
npx create-next-app@latest blob-demo
cd blob-demo
```

Install the Blob SDK:

```bash
npm install @vercel/blob
```

## Upload from the server

The simplest upload sends a file to a Route Handler. The handler validates it and writes it to Blob.

Create `app/api/avatar/route.ts`:

```ts
import { put } from '@vercel/blob'

const allowedTypes = new Map([
  ['image/jpeg', 'jpg'],
  ['image/png', 'png'],
  ['image/webp', 'webp'],
])

export async function POST(request: Request) {
  const form = await request.formData()
  const file = form.get('file')

  if (!(file instanceof File)) {
    return Response.json(
      { error: 'Select a file' },
      { status: 400 },
    )
  }

  const extension = allowedTypes.get(file.type)

  if (!extension) {
    return Response.json(
      { error: 'Use a JPG, PNG, or WebP image' },
      { status: 415 },
    )
  }

  if (file.size > 4 * 1024 * 1024) {
    return Response.json(
      { error: 'The image must be smaller than 4 MB' },
      { status: 413 },
    )
  }

  const blob = await put(
    `avatars/${crypto.randomUUID()}.${extension}`,
    file,
    {
      access: 'public',
      addRandomSuffix: true,
    },
  )

  return Response.json(blob)
}
```

The route checks the file type and size before uploading.

Server uploads pass through the Function request body, whose limit is about 4.5 MB including multipart overhead. Keep this pattern for small files. For larger public uploads, use Vercel Blob client uploads so the browser sends the file directly after your server authorizes it.

It does not trust the original filename. Instead, it creates a pathname from a random ID and an extension chosen from the validated MIME type.

`addRandomSuffix` makes the final URL unique. This also avoids stale browser caches when someone replaces an avatar.

## Build the upload form

Replace `app/page.tsx` with:

```tsx
'use client'

import { useState } from 'react'

export default function Home() {
  const [url, setUrl] = useState('')
  const [error, setError] = useState('')

  async function uploadAvatar(formData: FormData) {
    setError('')

    const response = await fetch('/api/avatar', {
      method: 'POST',
      body: formData,
    })

    const result = await response.json()

    if (!response.ok) {
      setError(result.error)
      return
    }

    setUrl(result.url)
  }

  return (
    <main>
      <h1>Upload an avatar</h1>

      <form action={uploadAvatar}>
        <input
          type='file'
          name='file'
          accept='image/jpeg,image/png,image/webp'
          required
        />
        <button>Upload</button>
      </form>

      {error && <p>{error}</p>}
      {url && <img src={url} alt='Uploaded avatar' width='240' />}
    </main>
  )
}
```

Start the app:

```bash
npm run dev
```

Choose an image and upload it.

The server returns a public Blob URL. The browser can render that URL directly.

## Client validation is not security

The `accept` attribute helps the file picker show suitable files.

It does not protect the server. A caller can bypass the form and send any request.

Keep the type and size checks in the Route Handler.

For security-sensitive uploads, inspect the file's actual bytes instead of trusting `file.type`. Also consider malware scanning before making uploaded content available.

Add authentication before letting users upload:

```ts
const user = await auth()

if (!user) {
  return new Response('Unauthorized', { status: 401 })
}
```

Use the signed-in user ID in the pathname or store it beside the returned Blob URL in your database.

## Upload directly from the browser

Server uploads pass the complete file through your Function.

For larger files, use a client upload. The browser sends the bytes directly to Blob after your server issues a short-lived upload token.

Import `upload()` in the client:

```tsx
import { upload } from '@vercel/blob/client'

const blob = await upload(file.name, file, {
  access: 'public',
  handleUploadUrl: '/api/avatar/upload',
})
```

The token route decides what the browser may upload.

Create `app/api/avatar/upload/route.ts`:

```ts
import {
  handleUpload,
  type HandleUploadBody,
} from '@vercel/blob/client'

export async function POST(request: Request) {
  const body = (await request.json()) as HandleUploadBody

  try {
    const response = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async () => {
        // Authenticate the current user here.

        return {
          allowedContentTypes: [
            'image/jpeg',
            'image/png',
            'image/webp',
          ],
          maximumSizeInBytes: 5 * 1024 * 1024,
          addRandomSuffix: true,
        }
      },
      onUploadCompleted: async ({ blob }) => {
        console.log('Upload completed', blob.pathname)
      },
    })

    return Response.json(response)
  } catch (error) {
    return Response.json(
      { error: (error as Error).message },
      { status: 400 },
    )
  }
}
```

Never issue an upload token before authenticating and authorizing the user. Otherwise, anyone can use your store.

Use `onUploadCompleted` to save the final URL in your database. The callback comes from Vercel, so local callback testing might require a public tunnel.

## Store the Blob pathname

The `put()` and `upload()` functions return Blob metadata.

Keep at least these values:

```ts
{
  url: blob.url,
  pathname: blob.pathname,
  contentType: blob.contentType
}
```

Store them with the record that owns the file.

For example, an avatar row should include the user ID and Blob pathname. This makes authorization and deletion much safer than accepting an arbitrary URL from the browser.

## Delete a Blob

Import `del()`:

```ts
import { del } from '@vercel/blob'

await del(blobUrl)
```

The deletion must run on the server.

Do not create a route that deletes any URL sent by the caller. Load the Blob URL from your database after checking that the signed-in user owns it:

```ts
const avatar = await db.avatar.findFirst({
  where: {
    id: avatarId,
    userId: user.id,
  },
})

if (!avatar) {
  return new Response('Not found', { status: 404 })
}

await del(avatar.url)
```

Delete the database record after Blob confirms the deletion.

## Upload a private file

Create a separate private store for documents.

Upload with:

```ts
import { put } from '@vercel/blob'

const blob = await put('invoices/2026-0042.pdf', file, {
  access: 'private',
})
```

Private Blob is generally available. Vercel Functions can use short-lived OIDC authentication, so new private stores do not need a long-lived read-write token in the application.

The returned private URL will not work as a public link.

## Serve a private file

Create an authenticated route and fetch the object with `get()`:

```ts
import { get } from '@vercel/blob'

export async function GET(request: Request) {
  const user = await auth()

  if (!user) {
    return new Response('Unauthorized', { status: 401 })
  }

  const fileId = new URL(request.url).searchParams.get('fileId')

  if (!fileId) {
    return new Response('Not found', { status: 404 })
  }

  const file = await findPrivateFileForUser(user.id, fileId)

  if (!file) {
    return new Response('Not found', { status: 404 })
  }

  const result = await get(file.pathname, {
    access: 'private',
  })

  if (!result || result.statusCode !== 200) {
    return new Response('Not found', { status: 404 })
  }

  return new Response(result.stream, {
    headers: {
      'content-type': result.blob.contentType,
      'x-content-type-options': 'nosniff',
      'cache-control': 'private, no-store',
    },
  })
}
```

`auth()` and `findPrivateFileForUser()` represent your application's authentication and authorization. The route accepts an opaque application file ID, then looks up the exact stored pathname for that user. Do not pass a user-supplied pathname directly to `get()`.

For a large private download, consider a short-lived signed URL. It grants one operation on one pathname without putting your Function in the data path.

## Protect the store credential

Older public-store setups use `BLOB_READ_WRITE_TOKEN`.

Treat it like a password:

- never prefix it with `NEXT_PUBLIC_`
- never return it from an API route
- never put it in client code
- never commit `.env.local`
- rotate it if it appears in logs or Git history

The browser should receive a Blob URL or a narrow upload token, not the store's full credential.

## Treat files as immutable

Blob files are cached.

Overwriting the same pathname can leave an older version in a browser or CDN cache for a short time. The simplest solution is to create a new pathname for every version.

This is why our avatar route uses a random pathname.

Upload the replacement, update the database to point at it, then delete the old Blob.

That small rule avoids most cache invalidation problems.

The [general Vercel tutorial](https://flaviocopes.com/vercel/) covers deploying the application and managing its environments. Once deployed, check Blob usage and operations from the project's Observability section.
