convex-file-storage
SKILL.md
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, notv.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; usegenerateUploadUrl()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 batchgetUrl()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
Weekly Installs
3
Repository
imfa-solutions/skillsGitHub Stars
1
First Seen
5 days ago
Security Audits
Installed on
opencode3
antigravity3
claude-code3
github-copilot3
codex3
kimi-cli3