Convex File Storage
Built-in file storage for every Convex deployment — upload, store, serve, and delete files with no extra packages.
Critical Rules
-
Use v.id("_storage") for storage ID validators, not v.string() .
-
Authenticate before generateUploadUrl() — never expose upload URLs to unauthenticated users.
-
Always delete both storage AND database record — ctx.storage.delete() removes the blob, but your metadata document in the DB must be deleted separately.
-
ctx.storage.store() is action-only — mutations cannot store blobs directly; use generateUploadUrl() for client uploads instead.
-
ctx.storage.getUrl() returns temporary URLs — don't persist them in the database; generate fresh URLs at query time.
-
HTTP Action uploads are limited to 20MB — use the upload URL method for larger files (no size limit, 2min upload timeout).
-
Use ctx.db.system.get() for metadata — ctx.storage.getMetadata() in actions is deprecated.
-
Use Promise.all() to batch getUrl() calls when listing multiple files with URLs.
Two Upload Methods
Feature Upload URLs (recommended) HTTP Actions
File size No limit 20MB max
Upload timeout 2 minutes Standard HTTP
URL expiry 1 hour N/A
CORS Not needed Required
Client steps 3 (get URL, POST file, save ID) 1 (POST with file)
Use case Large files, images, videos Small files, controlled server-side logic
Upload URLs Method (Recommended)
Step 1: Generate URL (mutation)
import { mutation } from "./_generated/server"; import { v } from "convex/values";
export const generateUploadUrl = mutation({ args: {}, returns: v.string(), handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); return await ctx.storage.generateUploadUrl(); }, });
Step 2: Save metadata (mutation)
export const saveFile = mutation({ args: { storageId: v.id("_storage"), fileName: v.string(), fileType: v.string(), fileSize: v.number(), }, returns: v.id("files"), handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
if (!user) throw new Error("User not found");
return await ctx.db.insert("files", {
storageId: args.storageId,
fileName: args.fileName,
fileType: args.fileType,
fileSize: args.fileSize,
uploadedBy: user._id,
uploadedAt: Date.now(),
});
}, });
Step 3: Client upload (React)
import { useMutation } from "convex/react"; import { api } from "../convex/_generated/api"; import { FormEvent, useRef, useState } from "react";
export default function FileUploader() { const generateUploadUrl = useMutation(api.files.generateUploadUrl); const saveFile = useMutation(api.files.saveFile); const fileInput = useRef<HTMLInputElement>(null); const [uploading, setUploading] = useState(false);
async function handleSubmit(event: FormEvent) { event.preventDefault(); const file = fileInput.current?.files?.[0]; if (!file) return;
setUploading(true);
try {
// 1. Get signed upload URL
const uploadUrl = await generateUploadUrl();
// 2. POST file directly to Convex storage
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) throw new Error(`Upload failed: ${result.statusText}`);
const { storageId } = await result.json();
// 3. Save metadata to your table
await saveFile({
storageId,
fileName: file.name,
fileType: file.type,
fileSize: file.size,
});
} finally {
setUploading(false);
}
}
return ( <form onSubmit={handleSubmit}> <input type="file" ref={fileInput} disabled={uploading} /> <button type="submit" disabled={uploading}> {uploading ? "Uploading..." : "Upload"} </button> </form> ); }
Storing Files in Actions
Use ctx.storage.store(blob) inside actions to store server-generated or fetched files:
"use node"; import { action } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values";
export const fetchAndStore = action({
args: { url: v.string(), title: v.string() },
returns: v.id("_storage"),
handler: async (ctx, args) => {
const response = await fetch(args.url);
if (!response.ok) throw new Error(Fetch failed: ${response.statusText});
const blob = await response.blob();
const storageId = await ctx.storage.store(blob);
await ctx.runMutation(internal.files.saveFile, {
storageId,
fileName: args.title,
fileType: blob.type || "application/octet-stream",
fileSize: blob.size,
});
return storageId;
}, });
Serving Files
Generate URLs in queries (recommended)
export const getFileUrl = query({ args: { storageId: v.id("_storage") }, returns: v.union(v.string(), v.null()), handler: async (ctx, args) => { return await ctx.storage.getUrl(args.storageId); }, });
// List files with URLs — batch getUrl calls with Promise.all export const listFilesWithUrls = query({ args: {}, handler: async (ctx) => { const files = await ctx.db.query("files").order("desc").take(50); return await Promise.all( files.map(async (file) => ({ ...file, url: await ctx.storage.getUrl(file.storageId), })) ); }, });
Serve via HTTP Actions (for access control at request time)
import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({ path: "/getFile", method: "GET", handler: httpAction(async (ctx, request) => { const storageId = new URL(request.url).searchParams.get("storageId"); if (!storageId) return new Response("Missing storageId", { status: 400 });
const blob = await ctx.storage.get(storageId as any);
if (!blob) return new Response("Not found", { status: 404 });
return new Response(blob, {
headers: {
"Content-Type": blob.type || "application/octet-stream",
"Cache-Control": "public, max-age=3600",
},
});
}), });
export default http;
Serving comparison: storage.getUrl() has no size limit and uses CDN caching. HTTP Actions are limited to 20MB response but allow custom logic (auth checks, headers, processing).
Deleting Files
Always delete both the storage blob and your database record:
export const deleteFile = mutation({ args: { fileId: v.id("files") }, returns: v.null(), handler: async (ctx, args) => { const file = await ctx.db.get(args.fileId); if (!file) throw new Error("File not found");
// Verify ownership
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
if (!user || file.uploadedBy !== user._id) throw new Error("Not authorized");
// Delete blob from storage, then record from database
await ctx.storage.delete(file.storageId);
await ctx.db.delete(args.fileId);
return null;
}, });
File Metadata (_storage System Table)
Every stored file has a document in the _storage system table:
Field Type Description
_id
Id<"_storage">
The storage ID
_creationTime
number
Upload timestamp (ms)
sha256
string
Base16 SHA-256 checksum
size
number
File size in bytes
contentType
string?
MIME type (if set during upload)
export const getStorageMetadata = query({ args: { storageId: v.id("_storage") }, handler: async (ctx, args) => { return await ctx.db.system.get(args.storageId); }, });
// List all stored files from system table export const listAllStorage = query({ args: {}, handler: async (ctx) => { return await ctx.db.system.query("_storage").collect(); }, });
Schema Pattern
import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";
export default defineSchema({ files: defineTable({ storageId: v.id("_storage"), fileName: v.string(), fileType: v.string(), fileSize: v.number(), uploadedBy: v.id("users"), uploadedAt: v.number(), }) .index("by_uploadedBy", ["uploadedBy"]) .index("by_uploadedAt", ["uploadedAt"]), });
Limits & Rules
Operation Limit
Upload URL file size No limit
Upload URL timeout 2 minutes
Upload URL expiry 1 hour
HTTP Action request/response 20MB
storage.getUrl() URL duration Temporary (hours)
Debugging Quick Reference
Problem Cause Fix
getUrl() returns null
File deleted or invalid ID Check if storage ID exists in _storage
Upload fails with 400 Missing Content-Type header Set headers: { "Content-Type": file.type } on POST
Upload URL expired URL older than 1 hour Generate fresh URL immediately before upload
CORS error on HTTP action Missing CORS headers Add Access-Control-Allow-Origin
- OPTIONS handler
File URL stops working URLs are temporary Never store URLs in DB; regenerate with getUrl()
ctx.storage.store() in mutation store() is action-only Use generateUploadUrl() for mutations, store() for actions
Reference Files
-
Common patterns: Image galleries, user avatars, document versioning, chat attachments, React Native upload -> See references/patterns.md
-
Advanced topics: HTTP action uploads with CORS, batch processing, scheduled cleanup, orphan detection, AI image generation, storage stats -> See references/advanced.md