Skip to main content

Uploading and Storing Files

Files can be uploaded by your users and stored in Convex.

File upload in React

To make implementing file upload even easier, we made a standalone React library, check out https://uploadstuff.dev/. You can follow the library docs on implementing both your file upload frontend and backend.

If you're not using React or have a more custom use case, follow the docs below.

Uploading files via upload URLs

Arbitrarily large files can be uploaded directly to your backend using a generated upload URL. This requires the client to make 3 requests:

  1. Generate an upload URL using a mutation that calls storage.generateUploadUrl().
  2. Send a POST request with the file contents to the upload URL and receive a storage ID.
  3. Save the storage ID into your data model via another mutation.

In the first mutation that generates the upload URL you can control who can upload files to your Convex storage.

Example: File Storage with Queries and Mutations

Calling the upload APIs from a web page

Here's an example of uploading an image via a form submission handler to an upload URL generated by a mutation:

src/App.tsx
import { FormEvent, useRef, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

export default function App() {
const generateUploadUrl = useMutation(api.messages.generateUploadUrl);
const sendImage = useMutation(api.messages.sendImage);

const imageInput = useRef<HTMLInputElement>(null);
const [selectedImage, setSelectedImage] = useState<File | null>(null);

const [name] = useState(() => "User " + Math.floor(Math.random() * 10000));
async function handleSendImage(event: FormEvent) {
event.preventDefault();

// Step 1: Get a short-lived upload URL
const postUrl = await generateUploadUrl();
// Step 2: POST the file to the URL
const result = await fetch(postUrl, {
method: "POST",
headers: { "Content-Type": selectedImage!.type },
body: selectedImage,
});
const { storageId } = await result.json();
// Step 3: Save the newly allocated storage id to the database
await sendImage({ storageId, author: name });

setSelectedImage(null);
imageInput.current!.value = "";
}
return (
<form onSubmit={handleSendImage}>
<input
type="file"
accept="image/*"
ref={imageInput}
onChange={(event) => setSelectedImage(event.target.files![0])}
disabled={selectedImage !== null}
/>
<input
type="submit"
value="Send Image"
disabled={selectedImage === null}
/>
</form>
);
}

Generating the upload URL

An upload URL can be generated by the storage.generateUploadUrl function of the MutationCtx object:

convex/messages.ts
import { mutation } from "./_generated/server";

export const generateUploadUrl = mutation(async (ctx) => {
return await ctx.storage.generateUploadUrl();
});

This mutation can control who is allowed to upload files.

The upload URL expires in 1 hour and so should be fetched shortly before the upload is made.

Writing the new storage ID to the database

Since the storage ID is returned to the client it is likely you will want to persist it in the database via another mutation:

convex/messages.ts
import { mutation } from "./_generated/server";

export const sendImage = mutation({
args: { storageId: v.id("_storage"), author: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
body: args.storageId,
author: args.author,
format: "image",
});
},
});

Limits

The file size is not limited, but upload POST request has a 2 minute timeout.

Uploading files via an HTTP action

The file upload process can be more tightly controlled by leveraging HTTP actions, performing the whole upload flow using a single request, but requiring correct CORS headers configuration.

The custom upload HTTP action can control who can upload files to your Convex storage. But note that the HTTP action request size is currently limited to 20MB. For larger files you need to use upload URLs as described above.

Example: File Storage with HTTP Actions

Calling the upload HTTP action from a web page

Here's an example of uploading an image via a form submission handler to the sendImage HTTP action defined next.

The highlighted lines make the actual request to the HTTP action:

src/App.tsx
import { FormEvent, useRef, useState } from "react";

const convexSiteUrl = import.meta.env.VITE_CONVEX_SITE_URL;

export default function App() {
const imageInput = useRef<HTMLInputElement>(null);
const [selectedImage, setSelectedImage] = useState<File | null>(null);

async function handleSendImage(event: FormEvent) {
event.preventDefault();

// e.g. https://happy-animal-123.convex.site/sendImage?author=User+123
const sendImageUrl = new URL(`${convexSiteUrl}/sendImage`);
sendImageUrl.searchParams.set("author", "Jack Smith");

await fetch(sendImageUrl, {
method: "POST",
headers: { "Content-Type": selectedImage!.type },
body: selectedImage,
});

setSelectedImage(null);
imageInput.current!.value = "";
}
return (
<form onSubmit={handleSendImage}>
<input
type="file"
accept="image/*"
ref={imageInput}
onChange={(event) => setSelectedImage(event.target.files![0])}
disabled={selectedImage !== null}
/>
<input
type="submit"
value="Send Image"
disabled={selectedImage === null}
/>
</form>
);
}

Defining the upload HTTP action

A file sent in the HTTP request body can be stored using the storage.store function of the ActionCtx object. This function returns an Id<"_storage"> of the stored file.

From the HTTP action you can call a mutation to write the storage ID to a document in your database.

To confirm success back to your hosted website, you will need to set the right CORS headers:

convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";

const http = httpRouter();

http.route({
path: "/sendImage",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Step 1: Store the file
const blob = await request.blob();
const storageId = await ctx.storage.store(blob);

// Step 2: Save the storage ID to the database via a mutation
const author = new URL(request.url).searchParams.get("author");
await ctx.runMutation(api.messages.sendImage, { storageId, author });

// Step 3: Return a response with the correct CORS headers
return new Response(null, {
status: 200,
// CORS headers
headers: new Headers({
// e.g. https://mywebsite.com, configured on your Convex dashboard
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
Vary: "origin",
}),
});
}),
});

You also need to handle the pre-flight OPTIONS request:

convex/http.ts
// Pre-flight request for /sendImage
http.route({
path: "/sendImage",
method: "OPTIONS",
handler: httpAction(async (_, request) => {
// Make sure the necessary headers are present
// for this to be a valid pre-flight request
const headers = request.headers;
if (
headers.get("Origin") !== null &&
headers.get("Access-Control-Request-Method") !== null &&
headers.get("Access-Control-Request-Headers") !== null
) {
return new Response(null, {
headers: new Headers({
// e.g. https://mywebsite.com, configured on your Convex dashboard
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Digest",
"Access-Control-Max-Age": "86400",
}),
});
} else {
return new Response();
}
}),
});