A convex versioned assets component for Convex.
npm install convex-versioned-assetsA Convex component for managing versioned assets with full history,
automatic CDN delivery, and real-time sync.




> Click to watch a full walkthrough of convex-versioned-assets in action.
This component powers the asset management system at
BookGenius
(GitHub), an interactive
ebook platform with AI-powered content.
Most file storage solutions treat uploads as simple key-value stores: upload a
file, get a URL. When you upload a new version, you get a new URL and must
update all references manually.
convex-versioned-assets takes a different approach:
- Stable references: An asset at images/hero always resolves to its
current published version
- Full version history: Every upload creates a new version; old versions are
archived, not deleted
- Instant rollback: Restore any previous version with a single mutation
- Direct CDN delivery: File URLs point directly to Cloudflare's edge
network, not through Convex
- Real-time sync: Changelog-driven subscriptions notify your app of any
changes
```
Traditional Storage convex-versioned-assets
āāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāā
Upload v1 ā URL_A Upload v1 ā images/hero ā v1 (published)
Upload v2 ā URL_B Upload v2 ā images/hero ā v2 (published)
ā v1 (archived)
Must update all refs! All refs auto-resolve to v2
Can't restore v1 Restore v1 anytime
| Feature | Description |
| ---------------------------- | ---------------------------------------- |
| Version history | Every upload preserved, never lost |
| Publish/archive workflow | Explicit states: published, archived |/path/to/asset
| Instant rollback | Restore any previous version |
| Audit trail | Full changelog with who/what/when |
| Direct CDN URLs | Bypass Convex for file delivery |
| Dual storage backends | Convex storage or Cloudflare R2 |
| Folder organization | Virtual filesystem with |
| Real-time sync | Subscribe to changes via changelog |
The fastest way to get started is with our setup CLI:
`bash1. Create a Convex project with auth
bun create convex@latest
The setup wizard will:
- ā
Create all required Convex files (authz, functions, versionedAssets,
generateUploadUrl)
- ā
Configure http.ts with asset routes
- ā
Set up environment variables (CONVEX_ADMIN_KEY, ADMIN_EMAILS)
- ā
Install admin UI dependencies
- ā
Optionally set up TanStack Router with
/admin routeAfter setup, run
bun dev and navigate to /admin to see the admin panel.š Full setup guide with all options and
troubleshooting.
$3
If you prefer manual configuration:
`bash
bun install convex-versioned-assets
``typescript
// convex/convex.config.ts
import { defineApp } from "convex/server";
import versionedAssets from "convex-versioned-assets/convex.config";const app = defineApp();
app.use(versionedAssets);
export default app;
`$3
`typescript
// 1. Start the upload (get presigned URL)
const { intentId, uploadUrl } = await ctx.runMutation(
components.versionedAssets.assetManager.startUpload,
{ folderPath: "images", basename: "hero", filename: "hero.png" },
);// 2. Upload to the presigned URL
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
// 3. Finalize (creates version, publishes automatically)
await ctx.runMutation(components.versionedAssets.assetManager.finishUpload, {
intentId,
size: file.size,
contentType: file.type,
});
`$3
`typescript
// convex/files.ts
import { query } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";export const getFileUrl = query({
args: { folderPath: v.string(), basename: v.string() },
handler: async (ctx, { folderPath, basename }) => {
return ctx.runQuery(
components.versionedAssets.assetManager.getPublishedFile,
{
folderPath,
basename,
},
);
},
});
``tsx
// React component
function Image({ path, name }: { path: string; name: string }) {
const file = useQuery(api.files.getFileUrl, {
folderPath: path,
basename: name,
});
if (!file) return null;
return
;
}
`When you upload a new version of
images/hero, all components using this query
automatically re-render with the new URL.HTTP Routes
Register HTTP routes to serve files directly via CDN:
`typescript
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerAssetRoutes } from "convex-versioned-assets";
import { components } from "./_generated/api";const http = httpRouter();
registerAssetRoutes(http, components.versionedAssets, {
pathPrefix: "/assets",
});
export default http;
`This exposes:
-
GET /assets/{folderPath}/{basename} - Serve the latest published version
- GET /assets/v/{versionId} - Serve a specific version by ID$3
Unlike solutions that route every file request through your backend,
convex-versioned-assets returns URLs that point directly to the CDN:
`
āāāāāāāāāāāāāāāāāāā 1. useQuery (reactive) āāāāāāāāāāāāāāāāāāā
ā ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā ā
ā React App ā returns { url, versionId } ā Convex ā
ā ā ā ā
āāāāāāāāāā¬āāāāāāāāā āāāāāāāāāāāāāāāāāāā
ā
ā 2. Direct request (no Convex hop!)
ā https://cdn.example.com/images/hero-v3.png
ā¼
āāāāāāāāāāāāāāāāāāā
ā Cloudflare ā ā Served from nearest edge
ā CDN ā ā ~10-50ms globally
āāāāāāāāāāāāāāāāāāā
`Version Management
$3
Each asset maintains a pointer to its current published version:
`
Asset: images/hero
āāā publishedVersionId ā points to v3
ā
āāā Versions:
āāā v1 (archived) - uploaded Jan 1
āāā v2 (archived) - uploaded Jan 15
āāā v3 (published) - uploaded Feb 1 ā current
`$3
`typescript
const versions = await ctx.runQuery(
components.versionedAssets.assetManager.getAssetVersions,
{
folderPath: "images",
basename: "hero",
},
);// Returns all versions with metadata:
// [
// { version: 3, state: "published", createdAt: ..., size: ..., contentType: ... },
// { version: 2, state: "archived", createdAt: ..., size: ..., contentType: ... },
// { version: 1, state: "archived", createdAt: ..., size: ..., contentType: ... },
// ]
`$3
`typescript
await ctx.runMutation(components.versionedAssets.assetManager.restoreVersion, {
versionId: previousVersionId,
});
// v1 is now published, v3 is archived
// All queries automatically return v1's URL
`Real-Time Sync with Changelog
The component maintains a changelog of all operations, enabling efficient sync:
`typescript
// Subscribe to changes since a cursor
const { changes, nextCursor } = await ctx.runQuery(
components.versionedAssets.changelog.listSince,
{
cursor: { createdAt: lastSync, id: "" },
limit: 100,
},
);
`Change types tracked:
-
folder:create, folder:update, folder:delete
- asset:create, asset:publish, asset:update, asset:archive,
asset:delete
- asset:move, asset:rename$3
The changelog enables powerful sync tools. See
convex-sync
in the BookGenius repo for a complete example that maintains a **live local
filesystem mirror** of your Convex assets.
> Note:
convex-sync will be moved to this repository soon.`
āāāāāāāāāāāāāāāāāāā WebSocket subscription āāāāāāāāāāāāāāāāāāā
ā Local Disk ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā Convex ā
ā ā changelog.listSince ā Asset Manager ā
ā /sync-folder/ ā ā ā
ā āāā images/ ā Initial sync + real-time ā changelog DB ā
ā ā āāā hero ā updates via cursor ā ā
ā āāā sounds/ ā ā ā
āāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāā
`The sync daemon:
- Performs initial sync of all folders and files
- Subscribes to real-time changelog updates via WebSocket
- Processes each change type (publish, archive, move, rename, delete)
- Tracks downloaded versions via filesystem extended attributes (xattr)
- Resumes from last cursor on restart (no re-download of unchanged files)
This pattern is useful for:
- Development/AI workflows: Let AI agents work with assets locally using
familiar fs tools
- Build pipelines: Sync assets to a build server for static site generation
- Backup systems: Maintain an offline copy of all assets
Storage Backends
$3
Built-in, zero configuration. Good for development and smaller files.
$3
For production workloads with global CDN delivery, lower egress costs, and
custom domains.
Prerequisites:
@convex-dev/r2 component
following their documentation
2. Create an R2 bucket with CORS configured for your domains
3. Set up a custom domain for public CDN accessConfigure the backend:
`typescript
await ctx.runMutation(
components.versionedAssets.assetManager.configureStorageBackend,
{
backend: "r2",
r2PublicUrl: "https://assets.yourdomain.com",
r2KeyPrefix: "myapp", // optional namespace
},
);
`Pass R2 credentials when uploading:
`typescript
const { intentId, uploadUrl } = await ctx.runMutation(
components.versionedAssets.assetManager.startUpload,
{
folderPath: "images",
basename: "hero",
filename: "hero.png",
r2Config: {
R2_BUCKET: process.env.R2_BUCKET!,
R2_ENDPOINT: process.env.R2_ENDPOINT!,
R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID!,
R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY!,
},
},
);
`See the detailed R2 setup guide for step-by-step
instructions including CORS configuration, custom domains, and troubleshooting.
Documentation
| Guide | Description |
| ------------------------------------------------------- | ----------------------------------------------- |
| Quick Start | Setup CLI guide with all options |
| Setting Up R2 | Configure Cloudflare R2 bucket, CORS, domains |
| Public Files | Serve files through Cloudflare CDN |
| Private Files | Auth-protected access with signed URLs |
| WebP via Cloudflare | High-performance image conversion via CF Worker |
| WebP in Convex | Convert images to WebP in Convex actions |
API Reference
$3
| Function | Description |
| ------------------------- | ------------------------------- |
|
configureStorageBackend | Set storage backend (convex/r2) |
| startUpload | Begin upload, get presigned URL |
| finishUpload | Complete upload, create version |
| createFolderByPath | Create a folder |
| restoreVersion | Restore a previous version |
| moveAsset | Move asset to different folder |
| renameAsset | Rename an asset |
| deleteAsset | Soft-delete an asset |$3
| Function | Description |
| ---------------------------- | ---------------------------------- |
|
getPublishedFile | Get published version with URL |
| listPublishedFilesInFolder | List all published files in folder |
| getAssetVersions | Get all versions of an asset |
| listFolders | List subfolders |
| getFolder | Get folder by path |
| changelog.listSince | Get changes since cursor |
| changelog.listForFolder | Get changes for specific folder |Demo
Run the example app:
`bash
npm install
npm run dev
``Apache-2.0