November 5, 2026
How to Handle Uploads, Images, Video and AI-Generated Files: 8 GitHub Repos for Storage, CDN…
Every app that lets people upload something ends up building the same plumbing. You need a way to get the bytes from the browser into…

By Shola Jegede
10 min read
- 1 1. get-convex/r2: signed uploads and file serving on Cloudflare R2
- 2 2. gilhrpenner/convex-files-control: access keys, expiring download links and cleanup
- 3 3. Mux: video uploads, assets and live streams that stay in sync
- 4 4. transloadit/convex: file processing with Assemblies, signed uploads and webhooks
- 5 5. imaxisXD/cloudinary-convex: image upload, transformation and asset tracking
Every app that lets people upload something ends up building the same plumbing. You need a way to get the bytes from the browser into storage, somewhere to keep who owns the file and who can see it, a way to serve it fast, and a job that deletes it when it's supposed to be gone. None of that is your actual product, and all of it takes longer than you'd expect.
In this list I'll go through eight GitHub repos that take on those jobs, so object storage, access control, image processing, video, a virtual filesystem and AI image generation. You can use any one of them on its own, and a few of them work really well together, which I'll show you at the end.
All eight are built for Convex, which is a backend that keeps your database, your server functions and live queries in one TypeScript project. Convex calls this kind of module a component, and in practice a component is just an open-source repo you install from npm, register in convex/convex.config.ts with app.use(...), and call from your own functions. Each one keeps its own tables, so it doesn't touch your schema.
1. get-convex/r2: signed uploads and file serving on Cloudflare R2
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install @convex-dev/r2.
If you're storing files in Cloudflare R2, the usual path is to write an endpoint that creates a signed URL, upload from the browser to that URL, and then write a second call that saves the file's key and metadata in your database. Those two halves drift apart really easily, because someone can close the browser tab in between and leave you with a file nobody knows about.
This repo wraps that whole flow. React and Svelte hooks generate the signed URL, upload to R2 and sync the metadata into your Convex database in one go, and from server actions you can also store a Blob, Buffer or Uint8Array directly with r2.store. To show a file, r2.getUrl(key, { expiresIn }) gives you a signed download URL, and getMetadata, listMetadata and pageMetadata read the records back. If you need more than one bucket, you just create more than one R2 instance, and there are callbacks for checking an upload before it starts and for running code after it finishes.
const r2 = new R2(components.r2);
export const list = query({
args: {},
handler: async (ctx) => {
const messages = await ctx.db.query("messages").collect();
return Promise.all(messages.map(async (m) => ({
...m,
imageUrl: await r2.getUrl(m.imageKey, { expiresIn: 86400 }),
})));
},
});const r2 = new R2(components.r2);
export const list = query({
args: {},
handler: async (ctx) => {
const messages = await ctx.db.query("messages").collect();
return Promise.all(messages.map(async (m) => ({
...m,
imageUrl: await r2.getUrl(m.imageKey, { expiresIn: 86400 }),
})));
},
});I'd use it when R2 is already your storage choice and you want the upload flow without writing it yourself. There are two limits worth knowing. The signed URLs expire (the default is 15 minutes) and skip Cloudflare's edge cache, so for permanent, CDN-cached URLs you attach a custom domain and build the URL from the key, and the README's CDN snippet has syntax errors, so check it before you paste it in. The client-facing generateUploadUrl also doesn't take a custom key, so if you need your own key, write a mutation that calls r2.generateUploadUrl(key).
2. gilhrpenner/convex-files-control: access keys, expiring download links and cleanup
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install @gilhrpenner/convex-files-control.
Once you've figured out "how do I upload", the next question is "who's allowed to download it, and for how long?" By hand, that's a table of grants, a token generator, a check that counts uses, and a cron that deletes expired rows, and most teams write the first two and forget all about the cron.
This repo keeps all those pieces together. Uploads use a two-step presigned flow, or optional HTTP routes with your own auth hook. Each file carries access keys, which can be user IDs or tenant IDs, and an optional expiry, while downloads go through grants that can have a maximum number of uses, an expiry time, an optional password, and a shareable link that works without logging in. A cleanup function removes expired uploads, grants and files, and it supports Convex storage and Cloudflare R2, and can even move a file between the two while keeping its keys and grants.
const grant = await ctx.runMutation(
components.convexFilesControl.download.createDownloadGrant,
{ storageId, maxUses: 1, expiresAt: Date.now() + 10 * 60 * 1000 },
);
const url = buildDownloadUrl({
baseUrl: "https://<your-convex-site>",
downloadToken: grant.downloadToken,
filename: "report.pdf",
});const grant = await ctx.runMutation(
components.convexFilesControl.download.createDownloadGrant,
{ storageId, maxUses: 1, expiresAt: Date.now() + 10 * 60 * 1000 },
);
const url = buildDownloadUrl({
baseUrl: "https://<your-convex-site>",
downloadToken: grant.downloadToken,
filename: "report.pdf",
});It's great for invoices, reports, private uploads and anything you send as a one-time link, but read the caveats first. The component doesn't do anything about your own file metadata or auth, so you write thin wrapper mutations that check the caller, and cleanup only runs if you schedule it, like with a cron. A password in a query string can end up in your logs, so the README recommends the x-download-password header or a POST flow. And moving a file between backends deletes the original object after the copy.
3. Mux: video uploads, assets and live streams that stay in sync
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install convex @mux/convex @mux/mux-node.
Video has a problem images don't. After an upload, the file still has to be processed, and your app only finds out about it later through webhooks. The hand-built version is a webhook endpoint, signature checks, tables for assets, uploads and live streams, and code that keeps them current when events show up out of order.
This component builds that layer for you. Mux's integration page lists tables for assets, uploads, liveStreams, events and videoMetadata, and webhooks update them as events come in, so queries on them are reactive and a UI watching a video's status changes the moment Mux reports it. A one-time backfill imports the assets you already have, and a metadata layer adds title, description, visibility, tags and ownership on top of the synced assets. Setup runs through npx @mux/convex init --component-name mux, which writes the config, migration, webhook and HTTP files for you.
export const listAssets = query({
handler: async (ctx) => ctx.runQuery(components.mux.catalog.listAssets, {}),
});export const listAssets = query({
handler: async (ctx) => ctx.runQuery(components.mux.catalog.listAssets, {}),
});Use it when Mux already hosts your video and you want the catalog inside Convex. There are a few things to check, though. The backfill is a one-time job and only covers assets, so live streams and uploads sync through webhooks, and you'll need a MUX_WEBHOOK_SECRET. The integration page doesn't describe the upload or playback flow, so read Mux's own docs for those parts.
4. transloadit/convex: file processing with Assemblies, signed uploads and webhooks
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with yarn add @transloadit/convex.
When uploaded files need work done on them, like resizing, transcoding, watermarking or pulling out the audio, you either run that work yourself or hand it off to a service. And handing it off still leaves you signing uploads, receiving webhooks and tracking results.
Transloadit calls a unit of processing an Assembly, and this component creates Assemblies from a Template or from inline steps, signs upload options for Uppy through @uppy/transloadit, and takes in webhooks with signature verification, either directly or through a queue. It stores three tables, assemblies, results (one row per output file) and storedAssets, and you can add storage receipts, a media listing, and a retention and deletion ledger on top.
Use it when processing is the hard part of your file pipeline, and take the security note seriously. The helper isn't a public endpoint, so your app has to authorize every caller and pick the pipelines, Templates and ownership fields on the server. The older makeTransloaditAPI and client.api() register public functions with no app authorization at all, and the README says not to use them in new apps. Assemblies expire after an hour by default unless you pass expires, deletion tombstones are kept forever, and listAlbumResults returns at most 500 rows. The package also needs Node.js 24.15 or later (or 26+), Yarn 4 and convex 1.43 or later.
5. imaxisXD/cloudinary-convex: image upload, transformation and asset tracking
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install @imaxis/cloudinary-convex.
Convex limits function arguments to 16 MB, so a naive base64 upload through a mutation breaks on bigger files. By hand, you solve that with direct browser-to-Cloudinary uploads, server-side signing, and a status record that tells your UI what's going on.
This component covers both paths. Small files (the README suggests switching at about 10 MB) go through a base64 upload, while big files, 100 MB and up, upload straight from the browser to Cloudinary with progress tracking, and only the metadata gets stored in Convex. Direct upload is three steps: generateUploadCredentials, the browser upload, and then finalizeUpload. Upload status goes through pending, uploading, completed and failed, and it's reactive across tabs, while an assets table keeps track of every file. It also builds transformation URLs with crop, gravity, quality, format, effects and overlays, and it exports React hooks like useCloudinaryUpload and useCloudinaryAssets.
// convex/cloudinary.ts
export const { upload, listAssets } = makeCloudinaryAPI(components.cloudinary);// convex/cloudinary.ts
export const { upload, listAssets } = makeCloudinaryAPI(components.cloudinary);Use it when Cloudinary is your image CDN and you want its assets queryable right next to your data. It calls Cloudinary's REST API directly instead of using an SDK. Responsive images, lazy loading and format selection aren't included, and the README points you to Cloudinary's React SDK for rendering. It also doesn't document any webhook setup, so metadata only stays current through the calls you make.
6. raymond-UI/uploadthingtrack: ownership, access and expiry on top of UploadThing
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install @mzedstudio/uploadthingtrack.
UploadThing stores your files, but it has no idea who owns them, who's allowed to see them, or when they should disappear, so teams that use it end up adding their own files table with the same columns every time.
This component is that table, built once. It records the URL, key, name, size, MIME type and upload time for each file, with an owner userId, and access can be public, private or restricted, set per file or per folder. Expiry follows a clear order: an explicit expiresAt wins, then a per-file ttlMs, then ttlByFileType, then ttlByMimeType, then defaultTtlMs, and if none of those are set, files never expire. Cleanup can also delete the expired files from UploadThing's servers, and a webhook handler verifies UploadThing callbacks with an HMAC SHA-256 signature. You can tag files, filter by user, folder, tag or MIME type, and list across users for galleries with the same access checks.
await uploadthing.cleanupExpired(ctx, { dryRun: true });
await uploadthing.cleanupExpired(ctx, { batchSize: 100 });await uploadthing.cleanupExpired(ctx, { dryRun: true });
await uploadthing.cleanupExpired(ctx, { batchSize: 100 });Use it if you've already gone with UploadThing and want ownership and lifetimes without writing them yourself. The usage stats show totals per user but don't enforce quotas. If a remote delete fails, the local record stays so the next run can try again, and the record shows remoteDeleteFailed and remoteDeleteError. The README doesn't mention orphan cleanup, so a file UploadThing still holds but that no record points to is yours to track down.
7. jamwt/convex-fs: a virtual filesystem on a global CDN
The code is on GitHub, it's listed on the Convex directory, and the install steps are on convexfs.dev.
Raw blob storage gives you IDs, but most apps want paths, folders and the ability to move or rename a file without copying bytes around. By hand, that's a tree of path rows, a reference-counting scheme so duplicates share storage, and a way to do a move atomically.
ConvexFS gives you that model. Files live at filesystem-style paths, and move, copy and delete run as transactions, with preconditions that prevent data races. Blobs are reference-counted and deduplicated automatically, and downloads use signed, time-limited URLs served from Bunny.net's edge network and CDN, where you can pass parameters to Bunny's edge rules for things like custom download filenames or image optimization. Deletes are soft, with grace periods you can configure, so you can recover from a mistake, and files can have an optional automatic expiry. You supply your own authorization logic for uploads and downloads, and the repo comes with a photo-gallery example app.
Use it when your product behaves like a file manager, with folders, renames and sharing. The README is short and sends you to convexfs.dev for installation and API details, so I haven't shown install commands or code here. It's in Beta, and the README says the API is stable, with breaking changes noted in the changelog and shipped in minor versions until 1.0. Bunny.net is a hard dependency for storage and CDN.
8. dperussina/convex-nano-banana: image generation and editing with Gemini
The code is on GitHub, it's listed on the Convex directory and npm, and you install it with npm install convex-nano-banana.
Files don't only come from users anymore, because a model can make them too, and that brings its own plumbing: call the Gemini API, wait, save the result somewhere, record who asked for it, and show progress while it runs.
This component does all that inside Convex. generate creates an image from a text prompt and gives you a generation ID, edit changes an image from inline data, and editFromStorage edits one you've already stored. Results get saved to Convex file storage, and get is a reactive query, so a UI sees the status go from pending to generating to complete or failed. History records timing in durationMs, and list, listByStatus, delete and deleteAllForUser manage it. The model name is just a string, so a newer Gemini model works without a package update, and the README lists gemini-2.5-flash-image as the default and gemini-3-pro-image-preview for up to 4K output.
const nanoBanana = new NanoBanana(components.nanoBanana, {});
export const generateImage = action({
args: { prompt: v.string() },
handler: async (ctx, args) =>
nanoBanana.generate(ctx, {
userId: user.id, // from your auth
prompt: args.prompt,
aspectRatio: "16:9",
}),
});const nanoBanana = new NanoBanana(components.nanoBanana, {});
export const generateImage = action({
args: { prompt: v.string() },
handler: async (ctx, args) =>
nanoBanana.generate(ctx, {
userId: user.id, // from your auth
prompt: args.prompt,
aspectRatio: "16:9",
}),
});Use it for avatar generators, mockup tools or any feature where a prompt turns into a stored image. One warning straight from the README itself: its React example passes the API key from the client and uses a placeholder userId, which goes against its own security advice, so in production, look up the key on the server and use authenticated user IDs. It doesn't give any quota or pricing details, and the Pro model is a preview model.
How the eight fit together
The pattern is the same across all of these, because a file goes on a journey. It gets uploaded, processed, stored and served, and eventually deleted, and each repo owns one or two steps of that journey and records state in Convex tables, so your UI can read progress as a live query.
If you want a complete image pipeline, you could upload to R2 with the first repo, add expiring links with the second, and use the last one to generate the images in the first place. If you went with UploadThing or Cloudinary, the sixth and fifth give you the database half you're missing, and if video is the product, Mux covers that on its own. The common mistake is adding a repo for every single step, so pick your storage backend first, because everything else follows from that choice.