R2 Media Pipeline

10 min readUpdated 29 Apr 2026

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

StepWhoWhat happens
1BrowserUser selects a file in the media uploader
2Next.js Server ActiongetPresignedUploadUrl() generates a 60-second PUT URL targeting R2
3Browserfetch(PUT, presignedUrl, file) — binary goes directly to R2, not through Next.js
4BrowserOn 200 OK from R2, call saveMediaRecord() Server Action with the public URL
5Server ActionCreates a media record in PocketBase with URL, MIME type, dimensions, alt text
6BrowserMedia 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.

src/lib/r2/client.ts
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;
}
typescript

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.

src/lib/r2/upload.ts (presigned URL generation)
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" };
  }
}
typescript

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 variableExample valuePurpose
CLOUDFLARE_R2_ACCOUNT_IDabc123def456...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_NAMEvelocms-mediaTarget bucket
CLOUDFLARE_R2_PUBLIC_URLhttps://media.velocms.orgPublic 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 tierStorage quota
Free500 MB
Pro10 GB
Business50 GB
Agency200 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.

src/lib/r2/upload.ts (delete on media record removal)
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 });
  }
}
typescript

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:

next.config.ts (remotePatterns for R2)
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: "/**",
      },
    ],
  },
};
typescript

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

FilePurpose
src/lib/r2/client.tsLazy S3Client singleton, getR2BucketName, getR2PublicUrl
src/lib/r2/upload.tsgetPresignedUploadUrl, deleteFromR2, sanitizeFilename
src/lib/media/quota.tsQuota check before presigned URL is issued
src/lib/media/watermark.tsSharp-based watermark application (Business/Agency)
src/lib/media/exif-extractor.tsEXIF metadata extraction, GPS strip
src/app/api/v1/media/route.tsPublic REST API for media list and upload