R2 Media Pipeline
VeloCMS stores all user-uploaded media in Cloudflare R2 — an S3-compatible object store with zero egress fees and global CDN delivery via Cloudflare's edge network. Files are never proxied through the Next.js server. Instead, the server generates a short-lived presigned PUT URL, the browser uploads directly to R2, and the resulting public URL is saved to PocketBase. This keeps server memory usage at zero regardless of file size.
Upload flow overview
| Step | Who | What happens |
|---|---|---|
| 1 | Browser | User selects a file in the media uploader |
| 2 | Next.js Server Action | getPresignedUploadUrl() generates a 60-second PUT URL targeting R2 |
| 3 | Browser | fetch(PUT, presignedUrl, file) — binary goes directly to R2, not through Next.js |
| 4 | Browser | On 200 OK from R2, call saveMediaRecord() Server Action with the public URL |
| 5 | Server Action | Creates a media record in PocketBase with URL, MIME type, dimensions, alt text |
| 6 | Browser | Media is available for insertion into posts |
R2 client setup
R2 is accessed via the AWS SDK v3 S3Client with a Cloudflare-specific endpoint. The client is a lazy singleton — it reads environment variables at first call, not at module load time, so build-time tree-shaking works when R2 vars are absent in the build container.
import { S3Client } from "@aws-sdk/client-s3";
let client: S3Client | null = null;
export function getR2Client(): S3Client {
if (client) return client;
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
if (!accountId || !accessKeyId || !secretAccessKey) {
throw new Error("R2 not configured. Set CLOUDFLARE_R2_ACCOUNT_ID, ...");
}
client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
return client;
}Presigned upload URL generation
The Server Action that generates presigned URLs lives in src/lib/r2/upload.ts. It sanitizes the original filename, prepends a tenant-scoped prefix and millisecond timestamp to prevent key collisions, and signs a PutObjectCommand with a 60-second expiry. The Content-Type in the signed command must exactly match the Content-Type header the browser sends — R2 rejects mismatches.
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getR2Client, getR2BucketName, getR2PublicUrl } from "./client";
export async function getPresignedUploadUrl(
filename: string,
contentType: string,
tenantSlug: string = "uploads"
): Promise<PresignedUploadResult | PresignedUploadError> {
try {
const r2 = getR2Client();
const bucket = getR2BucketName();
const publicBase = getR2PublicUrl();
const sanitized = sanitizeFilename(filename);
// Key format: {tenantSlug}/{timestamp}-{sanitized-filename}
const key = `${tenantSlug}/${Date.now()}-${sanitized}`;
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType, // must match PUT header exactly
});
const presignedUrl = await getSignedUrl(r2, command, { expiresIn: 60 });
return {
success: true,
presignedUrl,
publicUrl: `${publicBase}/${key}`,
key,
};
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "unknown" };
}
}Tenant-scoped key namespacing
All R2 object keys are prefixed with the tenant slug: demo-terminal/1714123456789-hero-photo.webp. This acts as a logical namespace within a single R2 bucket — all tenants share one bucket, but keys never collide. When a tenant is deleted, all their objects can be enumerated and removed by listing the prefix. The sanitizeFilename function lowercases the name and replaces any character outside [a-z0-9.-_] with a hyphen, preventing path traversal and encoding issues.
Public CDN delivery
Once an object is uploaded, it's served via the R2 public URL configured in CLOUDFLARE_R2_PUBLIC_URL. In production this is a custom domain connected through the Cloudflare dashboard (R2 bucket → Settings → Custom Domains). Cloudflare's edge handles caching, AVIF/WebP negotiation is not done automatically by R2 — the Next.js Image component handles format conversion for images rendered in the frontend.
| Environment variable | Example value | Purpose |
|---|---|---|
| CLOUDFLARE_R2_ACCOUNT_ID | abc123def456... | CF account for endpoint URL |
| CLOUDFLARE_R2_ACCESS_KEY_ID | <YOUR_KEY_ID> | R2 API token key ID |
| CLOUDFLARE_R2_SECRET_ACCESS_KEY | <YOUR_SECRET> | R2 API token secret |
| CLOUDFLARE_R2_BUCKET_NAME | velocms-media | Target bucket |
| CLOUDFLARE_R2_PUBLIC_URL | https://media.velocms.org | Public CDN base URL |
Quota enforcement
Media quotas are enforced before the presigned URL is issued, not after. The quota checker in src/lib/media/quota.ts reads the tenant's plan tier from site_settings, calculates current usage from the sum of media.size records for that tenant, and rejects the upload if the new file would exceed the limit. This prevents quota bypass via concurrent uploads.
| Plan tier | Storage quota |
|---|---|
| Free | 500 MB |
| Pro | 10 GB |
| Business | 50 GB |
| Agency | 200 GB |
Orphan cleanup
When a media record is deleted from PocketBase, the deleteFromR2() function in src/lib/r2/upload.ts deletes the corresponding R2 object. It swallows errors — if R2 is temporarily unavailable, the PocketBase record is still deleted cleanly and the R2 object becomes an orphan. A future reconciliation sweep can clean these up by diffing PocketBase media records against R2 key listings for each tenant prefix.
export async function deleteFromR2(key: string): Promise<void> {
if (!key) return;
try {
const r2 = getR2Client();
await r2.send(new DeleteObjectCommand({
Bucket: getR2BucketName(),
Key: key,
}));
} catch (err) {
// Best-effort — PB record deletion succeeds even if R2 is unavailable.
// Orphaned objects can be reconciled by a future sweep job.
log.error("R2: failed to delete object", { key });
}
}Image optimization interactions
R2 stores the original file as uploaded. Image optimization happens at render time via the Next.js Image component, which negotiates AVIF or WebP based on the Accept header and resizes to the requested width. For this to work, the R2 public domain must be added to the remotePatterns config in next.config.ts. The next.config.ts entry for the media domain looks like this:
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "media.velocms.org", // CLOUDFLARE_R2_PUBLIC_URL domain
pathname: "/**",
},
{
protocol: "https",
hostname: "*.r2.dev", // r2.dev dev bucket URLs
pathname: "/**",
},
],
},
};EXIF extraction
The server action pipeline optionally extracts EXIF metadata from uploaded images via src/lib/media/exif-extractor.ts. This runs server-side after the presigned URL flow, using the file buffer to extract fields like camera model, GPS coordinates, and capture timestamp. EXIF data is stored in the media record and can be displayed in the admin media library. GPS coordinates are stripped before the public URL is served — this is a privacy requirement, not an optional feature.
Watermarking
Business and Agency plan tenants can enable automatic watermarking in their site settings. When enabled, src/lib/media/watermark.ts applies the tenant's logo or text watermark to uploaded images using Sharp before the final URL is saved to PocketBase. The watermarked version replaces the original in R2 — the original is not retained. Watermarking only applies to new uploads; existing media is not retroactively processed.
Reference
| File | Purpose |
|---|---|
| src/lib/r2/client.ts | Lazy S3Client singleton, getR2BucketName, getR2PublicUrl |
| src/lib/r2/upload.ts | getPresignedUploadUrl, deleteFromR2, sanitizeFilename |
| src/lib/media/quota.ts | Quota check before presigned URL is issued |
| src/lib/media/watermark.ts | Sharp-based watermark application (Business/Agency) |
| src/lib/media/exif-extractor.ts | EXIF metadata extraction, GPS strip |
| src/app/api/v1/media/route.ts | Public REST API for media list and upload |