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)
The ID of the file in storage.
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.
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? }
File metadata object or null if not found. ID for referencing the file.
Hex-encoded SHA-256 checksum of file contents.
Size of the file in bytes.
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
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 );
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 );
},
});
The ID of the file to download.
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 ;
},
});
Expected SHA-256 hash to verify file integrity.
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 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:
Client requests upload URL:
// Client
const uploadUrl = await convex . mutation ( api . files . generateUploadUrl );
Client uploads file:
// Client
const result = await fetch ( uploadUrl , {
method: "POST" ,
headers: { "Content-Type" : file . type },
body: file ,
});
const { storageId } = await result . json ();
Client saves to database:
// Client
await convex . mutation ( api . files . saveFile , {
storageId ,
name: file . name ,
type: file . type ,
});
Image gallery
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.