Skip to content

Building blocks

Storage

Uploaded files live in R2, under one policy per purpose: user avatars, organization photos, and whatever you add. You work here when you accept a new kind of upload, change a size limit or a format, or serve files only to their owner.

How it works

  1. A purpose in storage.purposes names the R2 binding and bucket, the formats it accepts, its size cap, who may read it, and its Cache-Control.
  2. Your upload route calls readUpload, which checks the size and sniffs the file's first bytes, then newObjectKey and putObject, then writes the key to your row.
  3. A key is <purpose>/<owner>/<32 hex>.<ext>. modules/storage-routes.ts(Licensees only) registers GET /api/<purpose>/:owner/:file for every purpose, so objectSrc(key) is just /api/ plus the key.
  4. To replace a file, store the new one, point the row at it, then deleteObject the old one.

The shipped purposes are avatars and org-avatars. Both use the AVATARS binding and bucket, and both are public.

Add a purpose

Add an entry to storage.purposes in starter.config.ts(Licensees only). An invoice store that only the uploader can read:

invoices: {
  binding: 'INVOICES',
  bucket: 'my-app-invoices',
  formats: ['pdf'],
  maxBytes: 10 * 1024 * 1024,
  noun: 'invoice',
  access: 'owner',
  cacheControl: 'private, no-store',
},

Bind the bucket in wrangler.jsonc(Licensees only):

"r2_buckets": [
  { "binding": "AVATARS", "bucket_name": "nuxter-avatars" },
  { "binding": "INVOICES", "bucket_name": "my-app-invoices" },
],

Then regenerate the Env types, create the bucket before you deploy, and restart pnpm dev:

pnpm cf-typegen
pnpm exec wrangler r2 bucket create my-app-invoices

The purpose id is the key prefix, the URL segment and the config key at once, so it must match /^[a-z][a-z0-9-]*$/. pnpm dev and the build refuse a purpose whose binding wrangler.jsonc doesn't declare, or whose bucket differs from the one that binding points at. Locally, R2 runs in Wrangler's simulator and needs no bucket.

Accept an upload

The client posts multipart/form-data with the file in a field named file:

const body = new FormData()
body.append('file', file)

await $fetch('/api/invoices', { method: 'POST', body })

The route, server/api/invoices.post.ts:

import {
  deleteObject,
  newObjectKey,
  putObject,
  readUpload,
} from '#server/storage'
import { objectSrc } from '#shared/utils/storage'

export default defineEventHandler(async (event) => {
  const session = await requireVerifiedSession(event)

  const upload = await readUpload(event, 'invoices')
  const key = newObjectKey('invoices', session.user.id, upload.format)

  await putObject(event, 'invoices', key, upload)

  try {
    await useDrizzle(event)
      .insert(tables.invoices)
      .values({ userId: session.user.id, key })
  } catch (error) {
    await deleteObject(event, 'invoices', key)
    throw error
  }

  return { src: objectSrc(key) }
})

tables.invoices is your own table in server/database/custom/schema.ts (Database).

readUpload throws before anything is stored:

Statusdata.reasonWhenExtra data
400upload-missingNo non-empty file part
413upload-too-largeContent-Length or the file is over maxBytesmaxBytes
415upload-formatThe first bytes match none of the purpose's formatsformats

Word the refusal from data in the reader's language. The English statusMessage is built from the policy for logs: noun: 'invoice' gives "Choose an invoice up to 10 MB." and "Use a PDF invoice."

putObject stores the object with the sniffed format's Content-Type and fires storage:uploaded (Events). A failed put throws, so your row is never written for an object that isn't there.

Replace or delete a file

deleteObject(event, purpose, key, replacing?) never throws. A failed delete is swallowed and leaves the object in the bucket; only a delete that happened fires storage:deleted. It does nothing when key is empty or equals replacing, which protects the object the row points at now.

When two uploads can race, make the row write a compare-and-set on the key you read first, as server/auth/routes/avatar.put.ts(Licensees only) does:

const swapped = await useDrizzle(event)
  .update(tables.invoices)
  .set({ key })
  .where(
    and(eq(tables.invoices.id, invoiceId), eq(tables.invoices.key, previous)),
  )
  .returning({ id: tables.invoices.id })

if (swapped.length === 0) {
  await deleteObject(event, 'invoices', key)

  throw createError({ statusCode: 409, statusMessage: 'Reload and try again.' })
}

await deleteObject(event, 'invoices', previous, key)

With a plain update, the losing upload's object is referenced by nothing and never cleaned up.

Serve files

The read route checks the path first: the owner segment must be digits and the file 32 hex characters plus one of the purpose's own extensions. Anything else answers 404.

  • access: 'public' serves the object to anyone who has the URL, with no database read. The 32 random hex characters are what keep it unguessable.
  • access: 'owner' needs a verified session whose user id equals the key's owner segment. A signed-out request gets 401 and another user gets 404, so nobody learns the object exists. The response is always private, no-store, whatever cacheControl says.

owner reads the owner segment as a user id, so it only works for purposes whose keys are minted with a user id. org-avatars keys carry an organization id, which is why that purpose stays public.

Responses carry a Content-Type from the file's extension and a Content-Length from R2. The route ignores Range and always answers 200 with the whole object. Chrome and Firefox play audio and video from it but can't seek without refetching. Safari may refuse media without a 206, so add range support to serveObject in server/storage/serve.ts(Licensees only) before serving media to it.

Formats

A purpose's formats come from STORAGE_FORMATS in shared/storage/formats.ts(Licensees only). Each entry has the Content-Type, the label refusals use, and the byte check:

FormatContent-TypeRecognized by
jpgimage/jpegFF D8 FF
pngimage/pngThe 8-byte PNG signature
webpimage/webpRIFF then WEBP at byte 8
pdfapplication/pdf%PDF-
mp3audio/mpegID3, or an MPEG frame sync

To accept another format, add an entry with its mime, label and a matches function. UNSUPPORTED_BY_DESIGN lists the ones left out: SVG carries script that would run on your origin, GIF isn't needed by anything shipped (adding it is one entry), DOCX and XLSX start with the same bytes as any ZIP file, and plain text has no signature to sniff.

Avatars in the browser

AvatarDropzone.vue checks a picked file with imageFileSchema(purpose) from shared/schemas/profile.ts(Licensees only), which reads the size and types from the same purpose. The server checks both again and sniffs the bytes.

The avatars block in app/app.config.ts(Licensees only) makes UAvatar render a plain <img>. Through @nuxt/image the URL would go to an optimizer that can't read /api/avatars/**, and every avatar would fall back to initials, which UAvatar builds from alt.

Configuration

Each entry in storage.purposes:

FieldShipped for both purposesWhat it does
binding'AVATARS'The R2 binding in wrangler.jsonc.
bucket'nuxter-avatars'The bucket that binding points at. Errors print the create command.
formats['jpg', 'png', 'webp']Accepted formats, checked against the first bytes.
maxBytes2 * 1024 * 1024Largest file accepted.
noun'image'What English refusal messages call the file.
access'public''public' or 'owner'.
cacheControl'public, max-age=31536000, immutable'Sent on public reads. Ignored for owner.

There are no environment variables. The binding is the whole credential.

Good to know

  • immutable in cacheControl is safe only because every upload gets a fresh key. Never write to an existing key under that header.
  • Uploads are read into memory whole. That suits files of a few megabytes, not hundreds; a large-file purpose needs its own streaming route.
  • Nothing sweeps orphaned objects. A failed delete leaks the object, and on a public purpose it stays reachable at its URL.
  • The read route serves only keys shaped the way newObjectKey builds them, and a key whose owner segment isn't a number reports ownerId: 0 in the storage events. Build keys with newObjectKey.
  • flags.purpose names the purpose whose bucket the r2 feature-flag driver reads (Feature flags).

A link with a lock opens the kit’s private repository, which every licensee can read.