Image uploads with Cloudflare R2 and presigned URLs: one architecture, two platforms
Storyie lets users attach photos and screenshots to diary entries — on the web and on iOS and Android. That means one image upload infrastructure has to work across two runtimes with different file APIs, different compression tools, and different ways of handling environment variables.
This post covers how we designed that infrastructure around Cloudflare R2 and client-generated presigned URLs, why we chose not to share the implementation code across platforms, and what we learned running it.
TL;DR
- R2 has no egress fees and speaks the S3 API — a straightforward choice for image storage in an app where reads far outnumber writes.
- Clients generate presigned URLs directly using the AWS SDK with a write-only, bucket-scoped API token. This removes one server round trip and one endpoint to maintain.
- Deletion runs server-side via a Server Action because we do not want a delete-capable credential in the client.
- Web and mobile share the same architecture and naming conventions, but not the implementation: file selection, compression, and env var exposure all differ enough that a shared package would hurt more than it helps.
- Images are compressed in stages — resize first, reduce quality only if necessary — so most photos pass through quickly.
Aspect | Web (Next.js) | Mobile (Expo) |
|---|---|---|
File selection |
|
|
Compression | Canvas API ( |
|
File reading |
|
|
Env vars |
|
|
Upload progress |
|
|
Why Cloudflare R2
Three things drove the decision: cost, API compatibility, and CDN delivery.
R2 has no egress charge. S3 does. For a diary app, images are viewed far more often than they are written — users scroll through months of entries, each with photos. With S3, that traffic translates directly into a bill that scales with engagement. With R2, it does not. For a small team running a consumer app, that predictability matters.
The S3-compatible API means @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner work without modification. No new SDK to learn, no bespoke client to maintain.
Architecture: client-generated presigned URLs
Skipping the server round trip
The conventional pattern is: client asks the server for a presigned URL, server generates and returns it, client uploads directly to the bucket. We dropped the first step.
[Client] → generate presigned URL via AWS SDK → PUT directly to R2The client generates the presigned URL itself using a scoped API token. That removes one HTTP round trip on every upload and eliminates an upload-specific server endpoint.
The obvious question is whether this is safe. The API token is scoped to PutObject on a single bucket — nothing else. If it leaks, an attacker can upload files to that bucket but cannot read, list, or delete anything. We treat the risk as acceptable given the limited blast radius.
Deletion is different
Uploads go directly from the client. Deletions do not.
When a diary entry is deleted, the images embedded in it need to go too. We extract image URLs from the Lexical editor JSON inside a Server Action and call R2's DeleteObjects API from there.
[Server Action] → extract image URLs from Lexical JSON → R2 DeleteObjectsWe do not want a delete-capable credential in the client. Deletions also naturally fit the server-side flow that handles entry removal anyway. DeleteObjects supports up to 1,000 keys per request, so batching is straightforward.
Same architecture, different implementations
When we started, the temptation was to put the upload logic in a shared package and import it from both apps. We decided against it.
What is genuinely shared
The two upload services align on everything that doesn't touch platform APIs:
- The same R2 bucket and credential structure, using the same environment variable names.
- The same presigned URL generation:
@aws-sdk/client-s3and@aws-sdk/s3-request-presigner. - The same key naming scheme:
images/YYYY/MM/timestamp-random.ext. - The same upload mechanism:
XMLHttpRequestwith aprogressevent listener. - The same error model: a custom error type that carries a
retryableboolean.
What differs
File selection, compression, and environment variable exposure all differ at the platform level. Trying to abstract over them produces a leaky interface that is harder to navigate than two parallel, readable implementations.
What we did instead: align the class names, method signatures, and error types between the two services. When you need to change the upload logic, the web implementation is a clear template for what to do on mobile, and vice versa. The symmetry is maintained by convention rather than by a shared module.
Compression strategy
A photo from a modern phone is typically 3–10 MB. Storing that uncompressed inflates storage costs and slows down the feed. Our compression runs in four stages, stopping as soon as the result meets the target:
- Pass through — if the image is already under 2 MB and within 2048×2048 px, use it as-is.
- Resize — if either dimension exceeds 2048 px, scale down while preserving aspect ratio.
- Quality reduction — if the file still exceeds 2 MB after resizing, reduce JPEG quality starting at 0.8 and stepping down by 0.1 until it fits.
- Resolution fallback — if quality hits the floor and the file is still too large, scale the resolution down further and try again.
Most photos land in stage 1 or 2. Stage 3 is uncommon. Stage 4 is rare.
On web, we use createImageBitmap to decode the image and canvas.toBlob to re-encode it. No external library required. On mobile, expo-image-manipulator handles the equivalent operations natively.
Per-plan image limits
Storyie's free plan allows one image per diary entry; the Pro plan allows ten. The limits are constants in the shared @storyie/subscription package.
Both platforms enforce the limit before the image picker opens. If the entry is already at the limit, the action is blocked immediately — the user sees "you cannot add more" rather than "your upload was rejected." The server checks the same limit as a backstop, but the client check is what shapes the experience.
On web, the useImageUpload hook owns this check. On mobile, imageLimitService runs the same logic before calling the image picker.
What we noticed running it
Key design is hard to change later. Our key structure is images/YYYY/MM/timestamp-random.ext. Because image URLs are embedded in Lexical editor JSON stored in the database, changing the key format would break every image in every existing entry. The year/month prefix was a deliberate choice — it makes it easy to configure lifecycle policies that expire old objects later without having to touch the data.
fetch still cannot track upload progress. ReadableStream upload support is limited enough that it doesn't work reliably across browsers and isn't available in the React Native runtime at all. We use XMLHttpRequest for both platforms. The progress event is straightforward and the behavior is consistent. As of mid-2026, nothing has changed to make fetch viable here.
retryable on errors pays for itself. Tagging errors with whether a retry makes sense felt like over-engineering at first. In practice, it makes the UI branching trivial: network errors get a retry button, format or size errors do not. The classification lives in the service layer, not scattered across UI components.
Related Posts
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how the image node and upload flow fit into the broader editor architecture - Building a Monorepo with pnpm and TypeScript — how we structure shared and platform-specific code across the workspace
- Building a Cross-Platform Mobile App with Expo — the broader Expo context that the upload flow runs inside
Try Storyie
Attach a photo to a diary on storyie.com and it shows up immediately on the iOS app. The upload pipeline described here is what makes that work.