Cloudflare R2
Cloudflare R2 file storage
git clone https://github.com/bijectionhq/r2.git
cd r2
git checkout c4240ac2ab0c25534df13b72ec776f7d0aad98dbBijection repository access is required. Request access.
Description
Cloudflare R2 file storage
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/r2. Based on convex/r2. License: Apache-2.0. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
A R2 component for Convex.
Explore the demo ↗Upstream overview
From the upstream package documentation.
The Cloudflare R2 component provides file storage and serving capabilities using Cloudflare's R2 object storage service. It handles signed URL generation for uploads, syncs file metadata to your Convex database, and provides React/Svelte hooks for seamless client-side file operations. The component supports multiple buckets, custom object keys, and server-side file storage from actions.
Use cases
From the upstream package documentation.
- User-generated content uploads where users need to upload profile pictures, documents, or media files directly from your React or Svelte frontend
- Server-side file processing where actions download external images, generate documents, or process data that needs to be stored in R2 and associated with database records
- Multi-tenant applications where different customers or projects require separate R2 buckets with isolated file storage
- Content management systems that need to store and serve large files while keeping metadata searchable in Convex tables
- File sharing applications where you need to generate time-limited URLs for secure file access with custom expiration times
How it works
From the upstream package documentation.
The component creates an R2 instance that connects to your Cloudflare bucket using API credentials stored as environment variables. For uploads, it generates signed URLs through the generateUploadUrl function and provides useUploadFile hooks for React and Svelte that handle the complete upload flow including metadata syncing.
File metadata gets stored in Convex tables alongside the R2 storage, enabling you to query and associate files with other data. The clientApi method lets you define upload validation and post-upload callbacks with proper TypeScript typing when you pass your DataModel as a generic parameter.
For server-side operations, the r2.store method accepts Blobs, Buffers, or Uint8Arrays from actions and handles both R2 upload and metadata syncing. File serving uses r2.getUrl to generate time-limited access URLs with configurable expiration times, and r2.deleteObject removes files from both R2 and your database.
Upstream package FAQ
Does @convex-dev/r2 support file streaming?
Yes, the @convex-dev/r2 component supports streaming uploads and downloads for large files. This prevents memory issues when handling large files in your Convex functions and provides better performance for file operations.
How do I authenticate with Cloudflare R2 in Convex?
The @convex-dev/r2 component uses your Cloudflare R2 API credentials stored as Convex environment variables. You provide your account ID, access key, and secret key, and the component handles authentication automatically.
Can I use custom domains with R2 and this component?
Yes, @convex-dev/r2 supports custom domains configured in your Cloudflare R2 bucket settings. You can serve files through your own domain while still managing uploads and storage operations through the component.
What file types can I store using @convex-dev/r2?
The @convex-dev/r2 component can store any file type that Cloudflare R2 supports, including images, videos, documents, and binary files. There are no restrictions imposed by the component itself, only R2's standard limits.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Cloudflare R2 with Bijection.
Read: https://bijection.com/components/r2/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from convex/r2, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
Store and serve files with Cloudflare R2.
// or @convex-dev/r2/svelte for Svelte!
import { useUploadFile } from "@convex-dev/r2/react";
// Upload files from React
const uploadFile = useUploadFile(api.example);
// ...in a callback
const key = await uploadFile(file);
// Access files on the server
const url = await r2.getUrl(key);
const response = await fetch(url);
Check out the example app for a complete example.
Prerequisites
Cloudflare Account
- Create a Cloudflare account
- Create an R2 bucket
- Set the bucket name as an environment variable
R2_BUCKETin your Convex deployment vianpx convex env set R2_BUCKET <bucket-name>. - Add a CORS policy
to the bucket allowing GET and PUT requests from your Convex app. You can also
use '*' to allow all origins (use with caution).
[ { "AllowedOrigins": ["http://localhost:5173"], "AllowedMethods": ["GET", "PUT"], "AllowedHeaders": ["Content-Type"] } ] - Create an API token
- On the main R2 page in your Cloudflare dashboard, click Manage R2 API Tokens
- Click Create API Token
- Edit the token name
- Set permissions to Object Read & Write
- Under Specify bucket, select the bucket you created above
- Optionally change TTL
- Click Create API Token
- On the next screen you'll be provided with four values that you'll need later:
- Token Value:
R2_TOKEN - Access Key ID:
R2_ACCESS_KEY_ID - Secret Access Key:
R2_SECRET_ACCESS_KEY - Endpoint:
R2_ENDPOINT
- Token Value:
Convex App
You'll need a Convex App to use the component. Follow any of the Convex quickstarts to set one up.
Installation
Install the component package:
npm install @convex-dev/r2
Create a convex.config.ts file in your app's convex/ folder and install the
component by calling use:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import r2 from "@convex-dev/r2/convex.config.js";
const app = defineApp();
app.use(r2);
export default app;
Set your API credentials using the values you recorded earlier:
npx convex env set R2_TOKEN xxxxx
npx convex env set R2_ACCESS_KEY_ID xxxxx
npx convex env set R2_SECRET_ACCESS_KEY xxxxx
npx convex env set R2_ENDPOINT xxxxx
npx convex env set R2_BUCKET xxxxx
Note: Environment variables are only needed if you don't pass config directly
to the R2 constructor. You can also pass individual values to override
specific environment variables.
Using multiple buckets
To use multiple R2 buckets, create separate R2 instances with different
config:
import { R2 } from "@convex-dev/r2";
import { components } from "./_generated/api";
// Primary bucket — reads from R2_* environment variables
const primary = new R2(components.r2);
// Archive bucket — configured explicitly
const archive = new R2(components.r2, {
bucket: process.env.ARCHIVE_BUCKET!,
endpoint: process.env.ARCHIVE_ENDPOINT!,
accessKeyId: process.env.ARCHIVE_ACCESS_KEY_ID!,
secretAccessKey: process.env.ARCHIVE_SECRET_ACCESS_KEY!,
});
Each instance maintains its own config and S3 client. Metadata from all buckets coexists in the same Convex table, indexed by bucket name.
Uploading files
File uploads to R2 typically use signed urls. The R2 component provides hooks for React and Svelte that handle the entire upload process:
- generates the signed url
- uploads the file to R2
- stores the file's metadata in your Convex database
Important: For proper TypeScript typing in your callbacks, pass your DataModel type as a generic parameter to r2.clientApi<DataModel>(). This ensures that ctx parameters are properly typed with your database schema, and queries return Doc<"tableName"> instead of GenericDocument.
-
Instantiate a R2 component client in a file in your app's
convex/folder:// convex/example.ts import { R2 } from "@convex-dev/r2"; import { components } from "./_generated/api"; import type { DataModel } from "./_generated/dataModel"; export const r2 = new R2(components.r2); // Pass DataModel as a generic type parameter to get proper TypeScript typing // for all callback contexts. Without this, ctx will be typed as GenericDocument // instead of your specific table types. export const { generateUploadUrl, syncMetadata } = r2.clientApi<DataModel>({ checkUpload: async (ctx, bucket) => { // const user = await userFromAuth(ctx); // ...validate that the user can upload to this bucket }, onUpload: async (ctx, bucket, key) => { // ...do something with the key // This technically runs in the `syncMetadata` mutation, as the upload // is performed from the client side. Will run if using the `useUploadFile` // hook, or if `syncMetadata` function is called directly. Runs after the // `checkUpload` callback. }, }); -
Use the
useUploadFilehook in your component to upload files:React:
// src/App.tsx import { FormEvent, useRef, useState } from "react"; import { useAction } from "convex/react"; import { api } from "../convex/_generated/api"; import { useUploadFile } from "@convex-dev/r2/react"; export default function App() { // Passing the entire api exported from `convex/example.ts` to the hook. // This must include `generateUploadUrl` and `syncMetadata` from the r2 client api. const uploadFile = useUploadFile(api.example); const imageInput = useRef<HTMLInputElement>(null); const [selectedImage, setSelectedImage] = useState<File | null>(null); async function handleUpload(event: FormEvent) { event.preventDefault(); // The file is uploaded to R2, metadata is synced to the database, and the // key of the newly created object is returned. await uploadFile(selectedImage!); setSelectedImage(null); imageInput.current!.value = ""; } return ( <form onSubmit={handleUpload}> <input type="file" accept="image/*" ref={imageInput} onChange={(event) => setSelectedImage(event.target.files![0])} disabled={selectedImage !== null} /> <input type="submit" value="Upload" disabled={selectedImage === null} /> </form> ); }Svelte:
<script lang="ts"> import { useUploadFile } from "@convex-dev/r2/svelte"; import { api } from "../convex/_generated/api"; const uploadFile = useUploadFile(api.example); let selectedImage = $state<File | null>(null); async function handleUpload(file: File) { await uploadFile(file); selectedImage = null; } </script> <form onsubmit={() => { if (selectedImage) handleUpload(selectedImage); }} > <input type="file" accept="image/*" onchange={(e) => { selectedImage = e.currentTarget.files?.[0] ?? null; }} disabled={selectedImage !== null} /> <button type="submit" disabled={selectedImage === null}> Upload </button> </form>
Using a custom object key
The r2.generateUploadUrl function generates a uuid to use as the object key by
default, but a custom key can be provided if desired. Note: the
generateUploadUrl function returned by r2.clientApi does not accept a custom
key, as that function is a mutation to be called from the client side and you
don't want your client defining your object keys. Providing a custom key
requires making your own mutation that calls the generateUploadUrl method of
the r2 instance.
// convex/example.ts
import { R2 } from "@convex-dev/r2";
import { components } from "./_generated/api";
export const r2 = new R2(components.r2);
// A custom mutation that creates a key from the user id and a uuid. If the key
// already exists, the mutation will fail.
export const generateUploadUrlWithCustomKey = mutation({
args: {},
handler: async (ctx) => {
// Replace this with whatever function you use to get the current user
const currentUser = await getUser(ctx);
if (!currentUser) {
throw new Error("User not found");
}
const key = `${currentUser.id}.${crypto.randomUUID()}`;
return r2.generateUploadUrl(key);
},
});
Storing Files from Actions
Files can be stored in R2 directly from actions using the r2.store method.
This is useful when you need to store files that are generated or downloaded on
the server side.
// convex/example.ts
import { internalAction } from "./_generated/server";
import { R2 } from "@convex-dev/r2";
const r2 = new R2(components.r2);
export const store = internalAction({
handler: async (ctx) => {
// Download a random image from picsum.photos
const url = "https://picsum.photos/200/300";
const response = await fetch(url);
const blob = await response.blob();
// This function call is the only required part, it uploads the blob to R2,
// syncs the metadata, and returns the key. The key is a uuid by default, but
// an optional custom key can be provided in the options object. A MIME type
// can also be provided, which will override the type inferred for blobs. A
// Content-Disposition and Cache-Control header can also be provided if
// necessary.
const key = await r2.store(ctx, blob, {
key: "my-custom-key",
type: "image/jpeg",
disposition: "attachment; filename=example.jpg",
cacheControl: "max-age=3600",
});
// Example use case, associate the key with a record in your database
await ctx.runMutation(internal.example.insertImage, { key });
},
});
The store method:
- Takes a
Blob,Buffer, orUint8Arrayand stores it in R2 - Syncs metadata to your Convex database
- Returns the key that can be used to access the file later
Serving Files
Files stored in R2 can be served to your users by generating a URL pointing to a given file.
Generating file URLs in queries
The simplest way to serve files is to return URLs along with other data required by your app from queries and mutations.
A file URL can be generated from a object key by the r2.getUrl function of the
R2 component client.
// convex/listMessages.ts
import { components } from "./_generated/api";
import { query } from "./_generated/server";
import { R2 } from "@convex-dev/r2";
const r2 = new R2(components.r2);
export const list = query({
args: {},
handler: async (ctx) => {
// In this example, messages have an imageKey field with the object key
const messages = await ctx.db.query("messages").collect();
return Promise.all(
messages.map(async (message) => ({
...message,
imageUrl: await r2.getUrl(
message.imageKey,
// Options object is optional, can be omitted
{
// Custom expiration time in seconds, default is 900 (15 minutes)
expiresIn: 60 * 60 * 24, // 1 day
},
),
})),
);
},
});
File URLs can be used in img elements to render images:
// src/App.tsx
function Image({ message }: { message: { url: string } }) {
return <img src={message.url} height="300px" width="auto" />;
}
Serving Files from Cloudflare's CDN via Custom Domains
r2.getUrl() generates signed S3 URLs that expire (default 15 minutes) and
route directly to R2's S3 API endpoint, bypassing Cloudflare's edge cache. For
use cases where you need permanent, CDN-cached URLs (profile pictures, avatars,
public assets), you can connect a custom domain to your R2 bucket and build the
URL directly from the object key (eg., cdn.example.com, where example.com is a domain you own).
Set up an R2 custom domain
You'll first need to connect a custom domain to your R2 bucket - follow their docs to get started.
Cloudflare caches the most common file types automatically. They provide a complete list as well as info on how to enable caching for less common types in their docs.
Build a CDN URL from an object key
// convex/files.ts
import { R2 } from "@convex-dev/r2";
import { components } from "./_generated/api";
import { query } from "./_generated/server";
const r2 = new R2(components.r2);
// Build CDN URL from a stored object key.
function getCdnUrl(key: string): string {
// Replace with your domain
const base = "cdn.example.com"
return `${base}/${key.split('/').map(encodeURIComponent).join('/')`
}
export const listMessages = query({
args: {},
handler: async (ctx) => {
const messages = await ctx.db.query("messages").collect();
return messages.map((message) => ({
...message,
// Return cached, CDN served image
imageUrl: getCdnUrl(message.imageKey),
}));
},
});
Note: Don't use the
r2.devpublic development URL in production - it's rate limited and uncached. Use a custom domain instead.
Deleting Files
Files stored in R2 can be deleted from actions or mutations via the
r2.deleteObject function, which accepts an object key.
// convex/images.ts
import { v } from "convex/values";
import { mutation } from "./_generated/server";
import { R2 } from "@convex-dev/r2";
const r2 = new R2(components.r2);
export const deleteObject = mutation({
args: {
key: v.string(),
},
handler: async (ctx, args) => {
return await r2.deleteObject(ctx, args.key);
},
});
Accessing File Metadata
File metadata of an R2 file can be accessed from actions via r2.getMetadata:
// convex/images.ts
import { v } from "convex/values";
import { query } from "./_generated/server";
import { R2 } from "@convex-dev/r2";
const r2 = new R2(components.r2);
export const getMetadata = query({
args: {
key: v.string(),
},
handler: async (ctx, args) => {
return await r2.getMetadata(args.key);
},
});
This is an example of the returned document:
{
"ContentType": "image/jpeg",
"ContentLength": 125338,
"LastModified": "2024-03-20T12:34:56Z"
}
The returned document has the following fields:
ContentType: the ContentType of the file if it was provided on uploadContentLength: the size of the file in bytesLastModified: the last modified date of the file
Listing and paginating metadata
Metadata can be listed or paginated from actions via r2.listMetadata and
r2.pageMetadata.
// convex/example.ts
import { query } from "./_generated/server";
import { R2 } from "@convex-dev/r2";
const r2 = new R2(components.r2);
export const list = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
return r2.listMetadata(ctx, args.limit);
},
});
export const page = query({
args: {
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return r2.pageMetadata(ctx, args.paginationOpts);
},
});
Accessing metadata after upload
The onSyncMetadata callback can be used to run a mutation after every metadata
sync. The useUploadFile hook syncs metadata after every upload, so this
function will run each time as well.
Because this runs after metadata sync, the r2.getMetadata can be used to
access the metadata of the newly uploaded file.
// convex/example.ts
import { R2, type R2Callbacks } from "@convex-dev/r2";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
export const r2 = new R2(components.r2);
const callbacks: R2Callbacks = internal.example;
export const { generateUploadUrl, syncMetadata, onSyncMetadata } = r2.clientApi<DataModel>(
{
// Pass the functions from this file back into the component.
// Technically only an object with `onSyncMetadata` is required, the recommended
// pattern is just for convenience.
callbacks,
onSyncMetadata: async (ctx, args) => {
// args: { bucket: string; key: string; isNew: boolean }
// args.isNew is true if the key did not previously exist in your Convex R2
// metadata table
const metadata = await r2.getMetadata(ctx, args.key);
// log metadata of synced object
console.log("metadata", metadata);
},
},
);