From Local Scripts to R2: Uploads, Caching, and Content Data
Processing images is only half the workflow. The rest is uploading to R2, writing public URLs, and generating YAML that Next.js can consume.
After the three image variants exist, the next question is how the website should reliably access them.
I did not build an admin panel, and I did not expose upload permissions through an API route. This is a personal site, so write access belongs in local scripts and .env.local, not in production.
Uploading Through the S3 API
Cloudflare R2 is compatible with the S3 API, so the upload script uses @aws-sdk/client-s3. The important values come from .env.local:
R2_ACCOUNT_IDR2_BUCKETR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_PUBLIC_BASE_URLR2_PHOTOS_PREFIX
Each WebP upload gets a long-lived cache header:
CacheControl: "public, max-age=31536000, immutable"Once a key is generated, it should stay stable. That lets the browser and CDN cache the asset confidently.
The Manifest Is the Ledger
The processing script records local paths, R2 keys, public URLs, dimensions, EXIF data, file sizes, and upload time. The manifest becomes the ledger that processing, uploading, and content generation all share.
With it, later steps do not need to guess where files are or parse every original again.
YAML Is the Page Boundary
The final step is pnpm photos:generate, which turns the manifest into content/gallery/*.yaml. Velite reads those files and gives the app typed photo data.
The frontend can then focus on rendering photos, not on processing, uploading, naming, or caching them.
Reflection
The useful lesson was separation of responsibilities: local scripts own write access and recomputation, R2 owns static assets, YAML owns the content boundary, and the page owns rendering. Each piece is small, but together they form a dependable publishing path.

