# Video Uploads

Course lessons, screen recordings, a creator upload form: files of a few hundred megabytes to a few gigabytes, picked in the browser and played back on a page.

Three choices make that work:

- **`multipart: true`.** Every video goes up in parts, whatever it weighs, which is what buys pause, resume and per-part retry on a two hour upload.
- **A unique path, cached forever.** `uniquePath` plus `cache: 'immutable'` is one object per video, never overwritten, so its URL can be cached for a year.
- **A row per video.** Written as `pending` before the bytes and flipped to `ready` after them, so a page never links a file that is only half there.

The bytes go from the browser straight to storage. Your server authorizes the upload and records what landed, and never carries a gigabyte through a function.

This recipe uses a public bucket. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart).

---

## The handler

Unlike the other recipes, the row is written in `onBeforeUpload`, before a byte exists, and marked `pending`. A tab that dies halfway leaves a row still saying `pending`, which is what a cron can find and sweep. `onUploadComplete` only flips it to `ready`.

```ts lib/uploads.ts
import "server-only"
import * as z from "zod"
import { BlobError, uniquePath, uploadHandler, uploadRoute } from "@upstash/blob"
import { getUser } from "./auth"
import { db } from "./db"

const video = uploadRoute()({
  constraints: { contentTypes: ["video/*"], maxSize: "5gb" },
  multipart: true,
  input: z.object({ title: z.string().min(1) }),

  onBeforeUpload: async ({ request, input, file }) => {
    const user = await getUser(request)
    if (!user) throw new BlobError("unauthorized")

    const videoId = crypto.randomUUID()
    const path = uniquePath`videos/${user.id}/${file.name}`

    await db.videos.insert({
      id: videoId,
      ownerId: user.id,
      title: input.title,
      path,
      status: "pending",
    })

    return { path, cache: "immutable", state: { videoId } }
  },

  onUploadComplete: async ({ state, path, url, size }) => {
    try {
      // Last. The row stops looking abandoned only once everything else is written.
      await db.videos.update(state.videoId, { url, size, status: "ready" })
    } catch (e) {
      // A throw here deletes the object, so make it a deliberate refusal the user can retry from.
      console.error("[uploads] could not record", path, e)
      throw new BlobError("not_ready", { message: "could not save the video, try again" })
    }
    return { videoId: state.videoId }
  },
})

export const uploads = uploadHandler({ routes: { video } })
```

`onBeforeUpload` runs once per upload, so one upload is one row, and the row is the placeholder the rest of the app renders while the upload runs. A resume does not run it again: if the tab closes and the user picks the same file later, the SDK sends only the missing parts, and `onUploadComplete` flips the row that already exists.

`multipart: true` also means nothing is stored at the path until the upload completes. A tab that dies halfway leaves parts, which `abortStaleMultipartUploads()` on a cron clears, and a row still `pending`, which the same cron deletes once the parts are gone. See [Abandoned uploads](/blob/uploads/abandoned-uploads#the-cron).

A throw out of `onUploadComplete` deletes the object, so the `catch` turns a database failure into a refusal the user can retry from. `not_ready` is the 503 code, the one that means try again. See [Upload handler](/blob/uploads/upload-handler#onuploadcomplete) and [Errors](/blob/reference/errors#the-codes).

---

## The route

```ts app/api/upload/route.ts
import { uploads } from "@/lib/uploads"

export const { GET, POST } = uploads
```

```ts lib/upload-hooks.ts
"use client"

import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "./uploads"

export const { useUpload } = uploadHooks<typeof uploads>()
```

---

## The upload component

A gigabyte takes minutes, so the controls matter more than they do for a picture.

```tsx components/video-upload.tsx
"use client"

import { useUpload } from "@/lib/upload-hooks"

export function VideoUpload({ title }: { title: string }) {
  const { start, upload, accept } = useUpload("video")

  return (
    <>
      <input
        type="file"
        accept={accept}
        onChange={(e) => start({ file: e.target.files?.[0], input: { title } })}
      />

      {upload?.pending && <progress value={upload.percent} max={100} />}
      {upload?.status === "finishing" && <p>Finishing up...</p>}
      {upload?.stalled && <p>Connection is struggling, retrying...</p>}

      {upload?.canPause && upload.status === "uploading" && (
        <button onClick={() => upload.pause()}>Pause</button>
      )}
      {upload?.status === "paused" && <button onClick={() => upload.resume()}>Resume</button>}
      {upload?.pending && <button onClick={() => upload.cancel()}>Cancel</button>}

      {upload?.status === "error" && (
        <button onClick={() => upload.retry()}>{upload.error.message}</button>
      )}
    </>
  )
}
```

`canPause` is true throughout because the route sets `multipart: true`. `percent` sits at 99 while `onUploadComplete` runs, which is why `finishing` gets its own line rather than a bar that looks stuck. If the tab is closed and the user picks the same file again later, the upload resumes from the parts that landed, with no API to call. [Large files](/blob/uploads/large-files) covers all of that.

---

## Playing it back

Render the URL from your own row, and let `status` keep half-uploaded videos off the page.

```tsx components/video-player.tsx
import { db } from "@/lib/db"

export async function VideoPlayer({ id }: { id: string }) {
  const video = await db.videos.find({ id, status: "ready" })
  if (!video) return <p>Still processing...</p>

  return <video controls src={video.url} width={720} />
}
```

`url` is the public object URL, so the browser fetches the file directly and nothing streams through your app. The object is stored once and never overwritten, which is what makes `cache: 'immutable'` correct here: there is no stale version to worry about, and every play after the first can be served from cache.

For videos that must not be watchable by anyone holding the link, use a private bucket and sign each play with `signedReadUrl`, asking for an `expiresIn` comfortably longer than the video runs. See [Private documents](/blob/recipes/private-documents) for that shape.

---

## Deleting

Delete the row first, then the object. The page stops linking the video immediately, and if the second step fails the leftover is an object nobody links to rather than a player pointing at a 404. `del` treats an already missing object as success, so it is safe to retry.

```ts app/actions.ts
"use server"

import { Bucket } from "@upstash/blob"
import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"

const bucket = Bucket.fromEnv()

export async function deleteVideo(id: string) {
  const user = await getUser()
  const video = await db.videos.find({ id, ownerId: user?.id })
  if (!video) throw new Error("not found")

  await db.videos.delete(id)
  await bucket.del(video.path)
}
```

Closing an account is the same thing in bulk. The rows are the only thing that knows the paths, so read them first, delete the objects, then drop the rows. If it fails partway, run it again:

```ts
const videos = await db.videos.findMany({ ownerId: user.id })
await bucket.del(videos.map((v) => v.path))
await db.videos.deleteMany({ ownerId: user.id })
```

---

## Next steps

<CardGroup cols={2}>
  <Card title="Large files" href="/blob/uploads/large-files">
    Parts, pause, resume, retries, and where the threshold sits.
  </Card>

  <Card title="Abandoned uploads" href="/blob/uploads/abandoned-uploads">
    The pending row, and the cron that sweeps what never finished.
  </Card>

  <Card title="Constraints" href="/blob/uploads/constraints">
    What `video/*` expands to, and how `maxSize` is read.
  </Card>

  <Card title="Caching" href="/blob/bucket/caching">
    `immutable`, `revalidate`, and what a private bucket stores instead.
  </Card>
</CardGroup>
