> ## Documentation Index
> Fetch the complete documentation index at: https://upstash-dx-2982-blob-docs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Site Assets and CMS Media

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/site-assets-light.gif?s=744061a77cccaea03c195c900e696ec7" alt="Choose a cover image in the admin form, watch it upload, fill in the alt text and save, then replace it and see the site preview update on a new path" width="900" height="570" data-path="img/blob/site-assets-light.gif" />

  <img className="hidden dark:block" src="https://mintcdn.com/upstash-dx-2982-blob-docs/_w0BxdIkoSvH_8at/img/blob/site-assets-dark.gif?s=1c415ec610582a33c904cd2dabdecd5c" alt="Choose a cover image in the admin form, watch it upload, fill in the alt text and save, then replace it and see the site preview update on a new path" width="900" height="570" data-path="img/blob/site-assets-dark.gif" />
</Frame>

Files that belong to the site rather than to a user: the images and brochures an editor uploads from an admin page, and the fonts, stylesheets and downloads your build ships. Nobody owns them, everybody reads them, and they should be cached for as long as possible.

Three choices make that work:

* **An editor-only upload route.** The role check runs in `onBeforeUpload`, before anything is signed.
* **A row per media file.** Path, URL, alt text and uploader live in your table. The bucket holds bytes, your database is the index.
* **A path that changes when the bytes do.** `uniquePath` for uploads, a versioned filename for build assets, so `cache: 'immutable'` is always honest.

This recipe uses a public bucket, since every one of these files is meant to be linked from a page. If you have not created one yet, start with the [Quickstart](/blob/overall/quickstart). If you only need the deploy script, skip to [Build-time assets](#build-time-assets).

***

## The media handler

Editors upload from the browser, so the bytes go straight to storage and your server only authorizes them.

```ts lib/uploads.ts theme={"system"}
import "server-only"
import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
import { getUser } from "./auth"
import { db } from "./db"

export const uploads = uploadHandler({
  constraints: { contentTypes: ["image/*", "application/pdf"], maxSize: "25mb" },

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

    return {
      path: uniquePath`media/${file.name}`,
      cache: "immutable",
      metadata: { uploader: user.id },
    }
  },

  onUploadComplete: async ({ uploadId, path, url, file, metadata }) => {
    try {
      // The browser retries this request on a flaky network, so upsert on uploadId.
      await db.media.upsert({
        id: uploadId,
        name: file.name,
        alt: "",
        path,
        url,
        uploaderId: metadata.uploader,
      })
    } 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 file, try again" })
    }
    return { mediaId: uploadId, url }
  },
})
```

`uniquePath` gives every upload its own object, so re-uploading a file called `hero.png` never replaces last month's `hero.png`. Because the path never repeats, `cache: 'immutable'` needs no invalidation at all.

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

Mount the handler, then bind the hooks to it.

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

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

```ts lib/upload-hooks.ts theme={"system"}
"use client"

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

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

***

## The admin uploader

Alt text is not a property of the object, it is a property of the row, so it is filled in after the upload lands.

```tsx components/media-uploader.tsx theme={"system"}
"use client"

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

export function MediaUploader() {
  const { start, upload, accept } = useUpload()

  return (
    <>
      <input type="file" accept={accept} onChange={(e) => start({ file: e.target.files?.[0] })} />
      {upload?.pending && <progress value={upload.percent} max={100} />}
      {upload?.status === "error" && <p>{upload.error.message}</p>}
      {upload?.status === "done" && (
        <form action={(data) => setAltText(upload.blob.data.mediaId, String(data.get("alt")))}>
          <img src={upload.blob.data.url} alt="" width={160} />
          <input name="alt" placeholder="Describe this image" />
          <button type="submit">Save</button>
        </form>
      )}
    </>
  )
}
```

```ts app/actions.ts theme={"system"}
"use server"

import { getUser } from "@/lib/auth"
import { db } from "@/lib/db"

export async function setAltText(id: string, alt: string) {
  const user = await getUser()
  if (user?.role !== "editor") throw new Error("forbidden")
  await db.media.update({ id, alt })
}
```

The rendered page reads the row and never asks the bucket anything:

```tsx theme={"system"}
const cover = await db.media.find({ id: post.coverMediaId })

<img src={cover.url} alt={cover.alt} />
```

***

## Build-time assets

Fonts, compiled CSS and static downloads have no editor and no row. They are written by a script at deploy time, to paths you choose yourself, and the path is the identifier.

```ts scripts/upload-assets.ts theme={"system"}
import { readFile } from "node:fs/promises"
import { Bucket } from "@upstash/blob"

const bucket = Bucket.fromEnv()

const assets = [
  { path: "assets/fonts/inter.woff2", file: "build/fonts/inter.woff2", contentType: "font/woff2" },
  { path: "assets/css/app.v3.css", file: "build/app.css", contentType: "text/css" },
  { path: "assets/downloads/whitepaper.pdf", file: "static/whitepaper.pdf", contentType: "application/pdf" },
]

for (const asset of assets) {
  const blob = await bucket.put(asset.path, await readFile(asset.file), {
    contentType: asset.contentType,
    cache: "immutable",
  })
  console.info(`[assets] ${blob.path}, ${blob.size} bytes`)
}
```

A file read from disk is a buffer, which carries its length but not its type, so declare `contentType` yourself. Run the script from your deploy command, after the build and before the site goes live.

***

## When one of them changes

`cache: 'immutable'` asks browsers and CDNs to keep the bytes for a year, so a changed file needs a path nothing has seen before: put the version in the filename, as `app.v3.css`, or use a content hash. The old object stays until you delete it, which is what makes a rollback free.

When a path genuinely has to stay stable, overwrite it and link `versionedUrl` instead of `url`. It is the same URL with the object's etag on the query, so it changes whenever the bytes do. Wherever your templates get asset URLs from, a generated manifest or an env var, write `versionedUrl` there:

```ts theme={"system"}
const blob = await bucket.put("assets/logo.svg", buffer, { contentType: "image/svg+xml", cache: "immutable" })

blob.versionedUrl // link this, not blob.url
```

The trade-off between the two, and the `revalidate` option for a URL you cannot version at all, is in [Caching](/blob/bucket/caching).

***

## Removing an asset

For CMS media, delete the row first, then the object. The page stops linking the file immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image.

Add this to `app/actions.ts`:

```ts theme={"system"}
import { Bucket } from "@upstash/blob"

const bucket = Bucket.fromEnv()

export async function deleteMedia(id: string) {
  const user = await getUser()
  if (user?.role !== "editor") throw new Error("forbidden")

  const media = await db.media.find({ id })
  if (!media) throw new Error("not found")

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

A build asset has no row, so there is only the object: `await bucket.del('assets/css/app.v2.css')`. `del` treats an already missing object as success, so both are safe to run again.

***

## Next steps

<CardGroup cols={2}>
  <Card title="Writing" href="/blob/bucket/writing">
    `put`, content types, and what bodies carry their own length.
  </Card>

  <Card title="Caching" href="/blob/bucket/caching">
    `immutable`, `revalidate`, and the versioned URL pattern in full.
  </Card>

  <Card title="Constraints" href="/blob/uploads/constraints">
    What `image/*` expands to, and why SVG is not in it.
  </Card>
</CardGroup>
