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.
uniquePathpluscache: 'immutable'is one object per video, never overwritten, so its URL can be cached for a year. - A row per video. Written as
pendingbefore the bytes and flipped toreadyafter them, so a page never links a file that is only half there.
The handler
Unlike the other recipes, the row is written inonBeforeUpload, 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.
lib/uploads.ts
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.
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 and Errors.
The route
app/api/upload/route.ts
lib/upload-hooks.ts
The upload component
A gigabyte takes minutes, so the controls matter more than they do for a picture.components/video-upload.tsx
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 covers all of that.
Playing it back
Render the URL from your own row, and letstatus keep half-uploaded videos off the page.
components/video-player.tsx
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 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.
app/actions.ts
Next steps
Large files
Parts, pause, resume, retries, and where the threshold sits.
Abandoned uploads
The pending row, and the cron that sweeps what never finished.
Constraints
What
video/* expands to, and how maxSize is read.Caching
immutable, revalidate, and what a private bucket stores instead.