> ## Documentation Index
> Fetch the complete documentation index at: https://vlyai-1c28d863.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# File uploads

> Direct-to-storage uploads, validation, previews. The patterns that don't break under load.

File uploads should never go through your application's request handler — direct-to-storage from the browser saves bandwidth, latency, and cost.

## The standard pattern

<Steps>
  <Step title="Client requests an upload URL">
    Mutation: `generateUploadUrl()` returns a one-time URL.
  </Step>

  <Step title="Client uploads bytes directly">
    `fetch(url, { method: "POST", body: file })`.
  </Step>

  <Step title="Client gets back a storage ID">
    Records the ID in the relevant row via a separate mutation.
  </Step>

  <Step title="Server serves the file">
    Via the storage URL when needed.
  </Step>
</Steps>

See [Convex file storage](/convex/file-storage) for the code.

## Validation

Validate **on the client** (UX) AND **on the server** (security):

```typescript theme={null}
// Client (UX)
function validateFile(file: File) {
  if (file.size > 10 * 1024 * 1024) throw new Error("File too large (max 10 MB)");
  if (!["image/png", "image/jpeg"].includes(file.type)) throw new Error("Wrong type");
}

// Server (security) — in the mutation that records the storage ID
const blob = await ctx.storage.get(storageId);
if (blob.size > 10 * 1024 * 1024) throw new Error("File too large");
// Inspect bytes for actual file type if security matters
```

Don't trust client-side checks alone. A malicious user can bypass them.

## Previews

For images, generate a thumbnail at upload:

```typescript theme={null}
// In a Convex action triggered after upload:
const blob = await ctx.storage.get(originalId);
const thumbnail = await resize(blob, 200, 200);
const thumbId = await ctx.storage.store(thumbnail);
await ctx.db.patch(rowId, { thumbStorageId: thumbId });
```

Use Cloudinary if you need on-the-fly transforms; see [Cloudinary integration](/integrations/cloudinary).

## Multiple files

For multi-file uploads (drag-and-drop a folder), upload in parallel with concurrency control:

```typescript theme={null}
async function uploadMany(files: File[]) {
  const queue = [...files];
  const concurrent = 3;
  const inflight: Promise<any>[] = [];

  while (queue.length || inflight.length) {
    while (inflight.length < concurrent && queue.length) {
      inflight.push(uploadOne(queue.shift()!));
    }
    await Promise.race(inflight);
  }
}
```

## Limits

* Convex max per file: 100 MB by default.
* Larger files: use [AWS S3 integration](/integrations/aws-s3) for multipart uploads.

## Related

<CardGroup cols={3}>
  <Card title="File uploads recipe" icon="upload" href="/recipes/file-uploads">
    Worked walkthrough.
  </Card>

  <Card title="Convex file storage" icon="hard-drive" href="/convex/file-storage">
    Reference.
  </Card>

  <Card title="Cloudinary" icon="image" href="/integrations/cloudinary">
    Image transformations.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.