convex-file-storage

Built-in file storage for every Convex deployment — upload, store, serve, and delete files with no extra packages.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "convex-file-storage" with this command: npx skills add imfa-solutions/skills/imfa-solutions-skills-convex-file-storage

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

rn-skia

No summary provided by upstream source.

Repository SourceNeeds Review
General

rn-reanimated

No summary provided by upstream source.

Repository SourceNeeds Review
General

convex-file-system

No summary provided by upstream source.

Repository SourceNeeds Review
General

rn-heroui

No summary provided by upstream source.

Repository SourceNeeds Review