convex-file-storage
Complete file handling including upload flows, serving files via URL, storing generated files from actions, deletion, and accessing file metadata from system tables
By waynesutton · 2,530 installs
npx skills add waynesutton/convexskills --skill convex-file-storage
Source repository · Upstream listing
Convex file storage
Produces the upload, store, serve, and delete functions for files in Convex. The one rule: persist the Id<" storage" , never the URL string. URLs are resolved in queries with ctx.storage.getUrl at read time.
Schema
Keep your own table for app metadata (name, owner, purpose) and point at storage by id. Convex keeps size, content type, and hash in the storage system table.
Upload flow
Three steps: a mutation hands out a short lived upload URL, the client POSTs the file to it, then a second mutation saves the returned storageId . Upload URLs expire after one hour, so generate a fresh one per upload and never store it.
Storing a blob from an action
ctx.storage.store is available in actions and HTTP actions, not in mutations. Plain fetch works in the default runtime, so "use node" is only needed when a Node library produces the bytes. When building bytes yourself, wrap them first: new Blob([bytes], { type: "application/pdf" }) . Without a type the file serves as application/octet stream .
Serving with getUrl
Resolve the URL inside the query that returns the file. The client renders url directly in img , iframe , or a download link.
getUrl returns null when the file was deleted. Handle that in the UI instead of assuming a string.
Serving through an HTTP action
Use this when the file needs an auth check, a custom Content Disposition , or a stable path at https://<deployment .convex.site/files/<storageId . ctx.storage.get returns the Blob . Add the route to the router in convex/http.ts .
Metadata from the storage table
Read metadata with ctx.db.system.get(storageId) in any query or mutation, as saveFile does above. ctx.storage.getMetadata is deprecated and should not appear in new code. The document shape:
The matching validator is v.object({ id: v.id(" storage"), creationTime: v.number(), contentType: v.optional(v.string()), sha256: v.string(), size: v.number() }) . ctx.db.system.query(" storage") lists every stored file, useful for finding orphans that no files row points to.
Deleting
Delete the blob and the row in the same mutation so neither is orphaned. Check ownership on the row, not by trusting the client. If several rows can share one storageId , only delete the blob when the last reference goes.
Common mistakes
Mistake Why it breaks Do instead
Saving the URL from getUrl or the upload URL in a document Upload URLs expire in an hour; stored URLs go stale after deletes Store Id<" storage" , resolve with getUrl in the query
v.string() for the storage id Loses type safety, accepts garbage v.id(" storage")
Calling ctx.storage.store in a mutation Not available there Use an action or HTTP action, then runMutation to save
Skipping Content Type on the upload POST File serves as application/octet stream , images will not render inline Set headers: { "Content Type": file.type }
Validating size and type only in the browser Anyone can POST to the upload URL directly Check again with ctx.db.system.get in the save mutation
ctx.storage.getMetadata(id) Deprecated ctx.db.system.get(id)
Deleting the files row but not the blob Storage bill keeps growing Delete both in one mutation
Uploading large files through an HTTP action 20MB request cap Use the upload URL flow
Checklist
[ ] Schema stores v.id(" storage") plus app level name, type, size, and owner
[ ] generateUploadUrl requires a signed in user, and the client POSTs with Content Type: file.type
[ ] Save mutation validates size and content type again via ctx.db.system.get and deletes rejected uploads
[ ] Queries return url from ctx.storage.getUrl and the UI handles null
[ ] Actions use ctx.storage.store(blob) with a correct type , then runMutation to save the reference
[ ] HTTP serving route checks auth before ctx.storage.get
[ ] Delete mutation removes the blob and the row together and is idempotent
[ ] No ctx.storage.getMetadata calls and no URL strings persisted in the database
Docs
https://docs.convex.dev/llms.txt
https://docs.convex.dev/file storage/upload files
https://docs.convex.dev/file storage/serve files
https://docs.convex.dev/file storage/file metadata