Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
Stone Maps

Media in the Field: Building the R2 Pipeline

#stonemaps#devlog#cloudflare#build

Media in the Field: Building the R2 Pipeline

The first time someone posts a photo to Stone Maps it needs to land somewhere. Not just somewhere — somewhere fast, somewhere cheap, somewhere that doesn't require me to run my own object storage server or pay S3 egress fees forever. The answer was Cloudflare R2, and getting it right took more than the obvious parts.

Why R2

R2 is S3-compatible — it speaks the same API — but without egress fees. For a journaling app where images are requested every time someone loads their feed, that matters. The AWS SDK works against it directly, which made migration from any prior S3 setup trivial (we didn't have one yet, but the SDK compatibility still meant there was nothing new to learn).

The setup in lib/r2.ts is deliberately minimal:

typescript
const r2Client = new S3Client({
  region: 'auto',
  endpoint: R2_ENDPOINT,
  credentials: {
    accessKeyId: R2_ACCESS_KEY_ID,
    secretAccessKey: R2_SECRET_ACCESS_KEY,
  },
});

region: 'auto' is the R2-specific incantation. S3 requires a region; R2 routes by account and ignores it, but the SDK wants something there.

The Upload Flow

The upload pattern is presigned URLs. When a user attaches a photo, the client asks our API for a short-lived upload URL. The API generates one using PutObjectCommand and returns it. The client then uploads directly to R2 — no bytes pass through our servers.

typescript
const command = new PutObjectCommand({
  Bucket: R2_BUCKET,
  Key: key,
  ContentType: contentType,
});

const uploadUrl = await getSignedUrl(r2Client, command, { expiresIn: 3600 });

This matters at scale, but it also matters for feel. Upload starts immediately, there's no double-hop, and if our API is slow or goes down mid-session, the upload still completes.

The key structure tells a small story about who uploaded what:

posts/{userId}/{postId}/{timestamp}-{sanitizedFileName}

The timestamp makes keys unique even if someone uploads two files with the same name. The postId means we can find all media for a post without a database query. The userId creates a natural namespace.

The Proxy Problem

Here's the part that wasn't obvious.

The naive approach to serving uploaded files is to store a public URL in the database and serve it directly. R2 supports public buckets. You'd store https://media.stonemaps.app/posts/... and render it in an <img> tag.

The problem: that requires configuring a custom domain, setting up DNS, and having that domain actually exist and resolve. We had the string media.stonemaps.app in the codebase. We did not have that domain. So in production, every image was broken.

The fix is a proxy route: /api/r2/[...key]. When a request arrives for a key, the route generates a presigned GetObjectCommand URL and returns a 302 redirect to it. The image loads from R2 via a signed, time-limited URL. No public bucket. No custom domain. No DNS to configure.

typescript
// lib/r2.ts
export function getPublicUrl(key: string): string {
  return `/api/r2/${key}`;
}

What we store in the database is now just /api/r2/{key}. Root-relative. Works on localhost. Works on Vercel. Works wherever the app is deployed.

For legacy records — the brief period when we stored https://media.stonemaps.app/... — there's a normalizer that rewrites them on read:

typescript
export function resolveMediaUrl(url: string): string {
  if (url.startsWith('https://media.stonemaps.app/')) {
    const key = url.slice('https://media.stonemaps.app/'.length);
    return `/api/r2/${key}`;
  }
  return url;
}

This keeps old database records working without a migration.

The Mosaic

Once you can store multiple images per post, you need to display them. A single image is straightforward. Two images side by side is still fine. Three or more needs a decision.

The journal renders a mosaic — a CSS grid layout that adapts to the number of images in the post. One image fills the width. Two split it. Three creates a 2-1 or 1-2 arrangement depending on aspect ratio. Four makes a square grid.

It's a small thing but it matters. The journal is meant to feel like a record of being somewhere, and a photo mosaic does more to convey that than a scrolling filmstrip.

What's Still Rough

The proxy route adds a hop to every image request. The 302 redirect means the browser fetches twice: once to our API, once to R2. For a journaling app where images are mostly loaded once and then cached, this is fine. If we ever need to serve thousands of concurrent viewers, a CDN in front of R2 (Cloudflare's own or otherwise) would collapse that to zero hops.

We also haven't implemented image resizing. Uploaded files land at their original resolution. For profile photos and thumbnails this is wasteful; a 12MB DSLR shot doesn't need to be delivered to a phone. That's a future problem — for now, the pipeline works and the images show up.