File uploads
A file field is one line:
image: field.file({ accept: ["image"], maxBytes: 5 * 1024 * 1024, required: false }),or on the command line:
--fields 'image:file:[image]:5mb?'The form gets a drop zone. The bytes go to your storage driver. The column holds a key.
What a file field stores
Section titled “What a file field stores”Not the file. The column is text holding an object key:
products/image/2026/09/d0b8f166-add4-4fd6-9a55-b031135a4020-red-square.png└─ table └─ field └─ date └─ uuid └─ the name givenThe bytes live in whichever store you configured. Objects are private by
default, so there is no URL to put
in an <img> — one is signed on demand and expires. That is why a thumbnail
does a short round trip instead of rendering straight from the column.
The key’s prefix is <table>/<field>/, and the validator enforces it. A
request cannot attach an object that was uploaded for a different field:
{ "error": "Validation failed.", "issues": [{ "path": "image", "message": "This file wasn't uploaded for this field" }] }The categories
Section titled “The categories”accept takes one or more of these. Each is a set of MIME types.
| Category | Accepts | Typical use |
|---|---|---|
image |
PNG, JPEG, GIF, WebP, AVIF | Avatars, product photos, covers |
pdf |
Invoices, contracts, reports | |
document |
DOC, DOCX, ODT | Uploaded paperwork |
spreadsheet |
XLS, XLSX, ODS | Imports, price lists |
csv |
CSV | Data imports |
text |
Plain text | Logs, notes |
video |
Any video/* |
Clips, recordings |
audio |
Any audio/* |
Voice notes, podcasts |
archive |
ZIP, GZIP, 7z, RAR | Bundles, backups, downloads you sell |
any |
Anything | A drive, an attachment box |
Examples
Section titled “Examples”A product photo
Section titled “A product photo”--fields 'image:file:[image]:5mb?'An image gets a thumbnail in the form, in the table and on the record page. Anything else shows its filename.
An avatar, small on purpose
Section titled “An avatar, small on purpose”avatar: field.file({ accept: ["image"], maxBytes: 512 * 1024, required: false, helpText: "A square picture works best. Up to 512 KB." }),A signed contract
Section titled “A signed contract”--fields 'contract:file:[pdf]:20mb?'Paperwork, in whatever form it arrives
Section titled “Paperwork, in whatever form it arrives”--fields 'attachment:file:[pdf,document,image]:25mb?'Several categories, so a scan, a Word file or a PDF all pass.
A price list to import
Section titled “A price list to import”--fields 'priceList:file:[spreadsheet,csv]:10mb?'Storing the upload is separate from reading it. To parse it, add an endpoint:
npx flare gen endpoint Import process --method POST --recordA digital product people buy
Section titled “A digital product people buy”--fields 'download:file:[archive,pdf]:2gb?'Downloads are the case where R2 pays for itself: it charges nothing for bandwidth out, which on a file-heavy product is usually the largest line on the bill.
A voice note
Section titled “A voice note”--fields 'recording:file:[audio]:25mb?'A drive: anything at all
Section titled “A drive: anything at all”file: field.file({ accept: ["any"], maxBytes: 100 * 1024 * 1024 }),any checks nothing but the size. Reach for it when the app really is a
drive — not to save writing a list.
--fields 'photo:file:[image]:5mb?' # 5mb, 500kb, 2gbfield.file({ accept: ["image"], maxBytes: 5 * 1024 * 1024 })The limit is enforced twice: the browser refuses the file, and the server refuses to sign an upload URL for it. A check only the browser does is not a check.
Sensible ceilings: an avatar 512 KB, a photo 5 MB, a PDF 20 MB, a video or a download much larger. Cloudflare Workers can stream large bodies, but a 2 GB upload over a phone connection is a poor experience whatever the platform allows.
What the form does
Section titled “What the form does”- You drop a file (or click to choose one).
- The browser checks the type and size against the field.
- It asks the server for a signed upload URL, which checks them again and
mints a key under
<table>/<field>/. - The bytes go straight to storage, with a progress bar.
- The key goes into the form. Nothing is saved to the record until you save.
An image shows a preview immediately — from the local file while uploading, then from a signed URL.
Images are shrunk before they are uploaded
Section titled “Images are shrunk before they are uploaded”An image field optimises in the browser, by default, with nothing to turn on.
5.5 MB 4000x3000 JPEG what somebody picked off their phone 256 KB 1600x1200 WebP what gets uploaded and stored-------------------------------------------------------------- 22x smaller, in about 300ms, measured in ChromiumA real photograph does better than that — the figure above is a synthetic image full of random noise, which is close to the worst case for a compressor. The point is the order of magnitude.
This matters more on Flare than on most frameworks. Uploaded bytes pass through your app on their way to storage — a Worker, or a Vercel function — so a 6MB photo is 6MB of transit and CPU you are paying for, on top of the storage. Shrinking first removes almost all of it.
What it does, and does not, touch
Section titled “What it does, and does not, touch”| PNG, JPEG, WebP, AVIF | resized to fit 1600×1600 and re-encoded |
| GIF | left alone — a canvas keeps only the first frame |
| SVG, PDF, CSV, video, anything else | left alone |
| An image it cannot decode | uploaded untouched |
That last row is deliberate. Losing somebody’s upload because a codec choked is worse than storing a large file.
The format is chosen per image rather than fixed: anything with real transparency stays lossless, so a logo is never flattened onto a black background.
Why the size limit is checked afterwards
Section titled “Why the size limit is checked afterwards”A 6MB photo that becomes 256KB is not over a 1MB limit. So for an image,
maxBytes is measured against what will be stored, not what was
picked — which is what makes avatar:file:[image]:1mb a usable field
rather than one that rejects every modern phone.
Anything that is not optimised is still measured as it is, because nothing is going to make it smaller.
There is a separate ceiling on what will be decoded at all. A decompression bomb — a solid-colour PNG that is 144 megapixels once decoded — will hang a mobile browser tab, so the optimiser refuses one before it tries.
Turning it off
Section titled “Turning it off”NEXT_PUBLIC_OPTIMISE_UPLOADS=falseUploads then send the original bytes, and the size limit goes back to being measured against the file somebody picked.
It is a NEXT_PUBLIC_ variable because the decision is made in the
browser, and those are substituted at build time — so changing it needs a
rebuild rather than a restart. That is the trade for not asking the server
whether to compress before every single upload.
Reasons you might: a field where the file has to stay byte-for-byte, a photography site where 1600px is a downgrade, or anywhere the original is the deliverable.
Changing it instead
Section titled “Changing it instead”The profile is a single object at the top of
components/dashboard/fields/file-field.tsx, in your repository:
const IMAGE_PROFILE = { max_width: 1600, max_height: 1600, quality: 0.82, format: "auto", max_pixels: 50_000_000,};Raise the dimensions for a photography site, lower the quality for
thumbnails, or delete the prepare() call and uploads go back to sending
the original bytes.
It is @gritframework/upload
underneath — MIT, no dependencies of its own — and it is imported only
when somebody actually picks an image, so a form without an image field
never loads a canvas encoder.
Where the bytes go
Section titled “Where the bytes go”STORAGE_DRIVER picks where they go. The default needs nothing set: on
Cloudflare it is the R2 bucket your app is already bound to, and on Next.js it
is whatever docker compose up or your R2_* variables point at.
STORAGE_DRIVER |
What it is | Needs | Stack |
|---|---|---|---|
r2 (default) |
The bound bucket on Cloudflare. On Next.js, anything S3-compatible: R2, MinIO, B2, S3 | nothing on Cloudflare; four R2_* variables on Next.js |
both |
disk |
The local filesystem, like Laravel’s local disk |
nothing | Next.js |
cloudinary |
A CDN with an image pipeline | three CLOUDINARY_* variables |
both |
uploadthing |
A CDN with an uploader | a token, an app id, and pnpm add uploadthing |
both |
flare create asks which one you want and writes it into the app’s env file with
the variables it needs. Each provider has a page of its own covering the account,
the dashboard screens and where every value comes from — see
storage providers.
disk is the one exception: a Worker has no filesystem to write to, and the
bound bucket already covers what it is for.
On Cloudflare these are secrets, so set them with
wrangler secret put CLOUDINARY_API_SECRET rather than in wrangler.jsonc.
Each one is an ordinary file in lib/storage/ in your repository. A driver
answers three questions — store these bytes, give them back, throw them away —
and everything above it is identical whichever you pick: the file field, the
drop zone, the size cap, the type check, createUploadUrl and
createReadUrl.
r2 — the default
Section titled “r2 — the default”On Cloudflare, nothing to do: the app is bound to a STORAGE bucket that
Wrangler provisions on first deploy and simulates locally as a directory.
On Next.js, four variables, and they work against any S3:
STORAGE_DRIVER=r2R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.comR2_BUCKET=myappR2_ACCESS_KEY_ID=…R2_SECRET_ACCESS_KEY=…Locally, docker compose up -d in a new app gives you MinIO on
127.0.0.1:9000 already pointed at by .env, and its console on :9001
shows what was uploaded. MinIO speaks S3, so this is the same code path you
deploy.
disk — no account, no network
Section titled “disk — no account, no network”Next.js only.
STORAGE_DRIVER=disk# STORAGE_DIR=storage/app # the defaultFiles land under storage/app/objects/, with the content type recorded
beside them in storage/app/meta/ — so a file called IMG_0421 with no
extension still comes back as an image rather than a download.
Good for developing without Docker, and for a single server you own where a
volume is simpler than a bucket. Add storage/ to .gitignore.
Two places it does not belong. Anything that scales horizontally: two instances do not share a filesystem, and the second one serves 404s for everything the first one wrote. And anything serverless — Vercel, Lambda, Workers — where the filesystem is read-only and whatever you do write does not outlive the request. Deployed to Vercel this driver raises an error saying so, rather than failing an upload for no stated reason.
cloudinary
Section titled “cloudinary”STORAGE_DRIVER=cloudinaryCLOUDINARY_CLOUD_NAME=…CLOUDINARY_API_KEY=…CLOUDINARY_API_SECRET=…No SDK — the driver signs the upload itself, which is a sorted query string
and a SHA-1, and posts the file. Cloudinary files an asset under image,
video or raw, and the key’s extension decides which, in both directions,
so an upload and the read that follows it always agree.
Re-uploading the same key replaces the asset and invalidates the CDN copy, rather than leaving the old bytes cached at the edge under a URL you have already handed out.
uploadthing
Section titled “uploadthing”STORAGE_DRIVER=uploadthingUPLOADTHING_TOKEN=…One variable: the app id is inside the token. The uploadthing package is
installed for you when you scaffold with --storage uploadthing; add it with
pnpm add uploadthing if you are switching an existing app over.
UploadThing mints its own file key on upload, and a driver is asked to store
bytes under a key it was given. The two are reconciled with customId,
which UploadThing accepts at upload, addresses in a delivery URL, and filters
by on delete — so your key stays your key and nothing has to be stored to map
between them.
Writing another one
Section titled “Writing another one”A driver is a StorageBucket: get, put, delete, and optionally
publicUrl. lib/storage/cloudinary.ts is a good one to copy — it is a CDN
driver written with nothing but fetch and Web Crypto, so it runs unchanged
on both stacks. Add it to the switch in lib/storage.ts, which is the only
place that knows which driver is in use.
A driver that needs Node — disk does — belongs in a Next.js app only.
Reading a file back in your own code
Section titled “Reading a file back in your own code”import { storage } from "@/lib/storage";
const url = await storage.createReadUrl({ key: product.image }); // expiresconst download = await storage.createReadUrl({ key, downloadAs: "invoice.pdf" });downloadAs sets the filename the browser saves it under.
Deleting
Section titled “Deleting”Removing a record does not remove its objects. That is deliberate: a delete you can undo is worth more than a few kilobytes, and an object with no row costs almost nothing.
To clean up, do it in a hook, where every path through the app runs it:
hooks: { afterDelete: async ({ row }) => { if (row.image) await storage.bucket.delete(String(row.image)); },},Several files on one record
Section titled “Several files on one record”A file field holds one key. For many, make the files a resource:
npx flare gen resource Attachment \ --fields 'file:file:[any]:50mb, caption:string?, post:belongsTo(Post)'Then attachments: field.hasMany("Attachment") on Post gives its record
page a table of them. Relationships covers the
pattern; the drive tutorial builds it with multi-file
upload.