Skip to main content
The storage API allows you to store and retrieve files in Convex.

Reading files

File reading is available in queries, mutations, and actions via ctx.storage.

getUrl

Get a URL for downloading a file.
const url = await ctx.storage.getUrl(storageId);
if (url === null) {
  throw new Error("File not found");
}
// Use the URL (e.g., return it to the client)
storageId
Id<'_storage'>
required
The ID of the file in storage.
return
string | null
A URL which fetches the file via HTTP GET, or null if the file no longer exists.The GET response includes a standard HTTP Digest header with a SHA-256 checksum.

getMetadata (deprecated)

Get metadata for a file.
const metadata = await ctx.storage.getMetadata(storageId);
Deprecated: Use ctx.db.system.get("_storage", storageId) instead:
const metadata = await ctx.db.system.get(storageId);
// metadata: { _id, _creationTime, sha256, size, contentType? }
storageId
Id<'_storage'>
required
The ID of the file.
return
FileMetadata | null
File metadata object or null if not found.
storageId
string
ID for referencing the file.
sha256
string
Hex-encoded SHA-256 checksum of file contents.
size
number
Size of the file in bytes.
contentType
string | null
Content type of the file if provided on upload.

Writing files

File writing is available in mutations via ctx.storage.

generateUploadUrl

Generate a short-lived URL for uploading a file.
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const generateUploadUrl = mutation({
  args: {},
  returns: v.string(),
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl();
  },
});
On the client, POST the file to this URL:
const uploadUrl = await generateUploadUrl();
const result = await fetch(uploadUrl, {
  method: "POST",
  headers: { "Content-Type": file.type },
  body: file,
});
const { storageId } = await result.json();
// Save storageId to your database
return
string
A short-lived URL for uploading a file via HTTP POST.The client should POST the file as the request body. The response will be JSON containing the newly allocated storageId:
{ "storageId": "kg2..." }

delete

Delete a file from storage.
await ctx.storage.delete(storageId);
storageId
Id<'_storage'>
required
The ID of the file to delete.
Once a file is deleted, any URLs previously generated by getUrl() will return 404 errors.

Action-specific methods

In actions, ctx.storage has additional methods not available in mutations.

get

Download a file as a Blob.
import { action } from "./_generated/server";
import { v } from "convex/values";

export const downloadAndProcess = action({
  args: { storageId: v.id("_storage") },
  handler: async (ctx, args) => {
    const blob = await ctx.storage.get(args.storageId);
    if (blob === null) {
      throw new Error("File not found");
    }
    
    // Process the file
    const text = await blob.text();
    console.log(text);
  },
});
storageId
Id<'_storage'>
required
The ID of the file to download.
return
Blob | null
A Blob containing the file contents, or null if the file doesn’t exist.
Note: Only available in actions and HTTP actions, not in mutations or queries.

store

Upload a Blob directly to storage.
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const uploadFromUrl = action({
  args: { url: v.string() },
  returns: v.id("_storage"),
  handler: async (ctx, args) => {
    // Fetch from external URL
    const response = await fetch(args.url);
    const blob = await response.blob();
    
    // Upload to Convex storage
    const storageId = await ctx.storage.store(blob);
    
    // Save to database via mutation
    await ctx.runMutation(internal.files.saveMetadata, {
      storageId,
      url: args.url,
    });
    
    return storageId;
  },
});
blob
Blob
required
The Blob to store.
options
object
sha256
string
Expected SHA-256 hash to verify file integrity.
return
Id<'_storage'>
The ID of the newly stored file.
Note: Only available in actions and HTTP actions. For client-side uploads from mutations, use generateUploadUrl() instead.

File metadata

File metadata is stored in the _storage system table. Query it like any other table:
const metadata = await ctx.db.system.get(storageId);
if (metadata) {
  console.log(metadata._id);           // Id<"_storage">
  console.log(metadata._creationTime); // number
  console.log(metadata.sha256);        // string
  console.log(metadata.size);          // number
  console.log(metadata.contentType);   // string | undefined
}
You can also query all files:
const allFiles = await ctx.db.system.query("_storage").collect();

Common patterns

Upload workflow

Typical file upload flow:
  1. Client requests upload URL:
// Client
const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
  1. Client uploads file:
// Client
const result = await fetch(uploadUrl, {
  method: "POST",
  headers: { "Content-Type": file.type },
  body: file,
});
const { storageId } = await result.json();
  1. Client saves to database:
// Client
await convex.mutation(api.files.saveFile, {
  storageId,
  name: file.name,
  type: file.type,
});
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const saveImage = mutation({
  args: {
    storageId: v.id("_storage"),
    caption: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");
    
    await ctx.db.insert("images", {
      storageId: args.storageId,
      caption: args.caption,
      userId: identity.tokenIdentifier,
    });
  },
});

export const listImages = query({
  args: {},
  handler: async (ctx) => {
    const images = await ctx.db.query("images").order("desc").take(50);
    
    // Add URLs to each image
    return await Promise.all(
      images.map(async (image) => ({
        ...image,
        url: await ctx.storage.getUrl(image.storageId),
      }))
    );
  },
});

File processing in actions

import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import sharp from "sharp";

export const generateThumbnail = action({
  args: { imageId: v.id("_storage") },
  returns: v.id("_storage"),
  handler: async (ctx, args) => {
    // Download original image
    const blob = await ctx.storage.get(args.imageId);
    if (!blob) throw new Error("Image not found");
    
    // Process with sharp
    const buffer = Buffer.from(await blob.arrayBuffer());
    const thumbnail = await sharp(buffer)
      .resize(200, 200, { fit: "cover" })
      .jpeg({ quality: 80 })
      .toBuffer();
    
    // Upload thumbnail
    const thumbnailBlob = new Blob([thumbnail], { type: "image/jpeg" });
    const thumbnailId = await ctx.storage.store(thumbnailBlob);
    
    // Save reference in database
    await ctx.runMutation(internal.images.saveThumbnail, {
      originalId: args.imageId,
      thumbnailId,
    });
    
    return thumbnailId;
  },
});

Delete file with cleanup

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const deleteImage = mutation({
  args: { imageId: v.id("images") },
  handler: async (ctx, args) => {
    const image = await ctx.db.get(args.imageId);
    if (!image) throw new Error("Image not found");
    
    // Delete from storage
    await ctx.storage.delete(image.storageId);
    
    // Delete from database
    await ctx.db.delete(args.imageId);
  },
});

Best practices

Store metadata separately

Create a table to store file metadata (name, description, owner, etc.) and reference the storageId. Don’t rely solely on the _storage system table.

Clean up deleted files

When deleting database records that reference files, also delete the files from storage to avoid orphaned files.

Validate file types

Check file types and sizes on the client before uploading, and validate again in your mutation after upload.

Use actions for processing

Process files (resize images, extract text, etc.) in actions where you can use Node.js libraries.

Set appropriate content types

Set the Content-Type header when uploading to ensure files are served with the correct MIME type.

Build docs developers (and LLMs) love