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
- A purpose in
storage.purposesnames the R2 binding and bucket, the formats it accepts, its size cap, who may read it, and itsCache-Control. - Your upload route calls
readUpload, which checks the size and sniffs the file's first bytes, thennewObjectKeyandputObject, then writes the key to your row. - A key is
<purpose>/<owner>/<32 hex>.<ext>.modules/storage-routes.ts(Licensees only) registersGET /api/<purpose>/:owner/:filefor every purpose, soobjectSrc(key)is just/api/plus the key. - To replace a file, store the new one, point the row at it, then
deleteObjectthe 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:
| Status | data.reason | When | Extra data |
|---|---|---|---|
| 400 | upload-missing | No non-empty file part | |
| 413 | upload-too-large | Content-Length or the file is over maxBytes | maxBytes |
| 415 | upload-format | The first bytes match none of the purpose's formats | formats |
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 alwaysprivate, no-store, whatevercacheControlsays.
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:
| Format | Content-Type | Recognized by |
|---|---|---|
jpg | image/jpeg | FF D8 FF |
png | image/png | The 8-byte PNG signature |
webp | image/webp | RIFF then WEBP at byte 8 |
pdf | application/pdf | %PDF- |
mp3 | audio/mpeg | ID3, 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:
| Field | Shipped for both purposes | What 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. |
maxBytes | 2 * 1024 * 1024 | Largest 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
immutableincacheControlis 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
newObjectKeybuilds them, and a key whose owner segment isn't a number reportsownerId: 0in the storage events. Build keys withnewObjectKey. flags.purposenames the purpose whose bucket ther2feature-flag driver reads (Feature flags).
A link with a lock opens the kit’s private repository, which every licensee can read.