All posts
Development bunnycloudflarer2s3

Rolling your own media library

N

Nadim

April 21, 2026

·

5 min read

Table of contents

What's Actually Hiding Behind a File Input

Uploading files is almost always a pain. Unlike inserting data — where complexity is mostly limited to validating that a value matches the right type — files touch multiple moving parts. You need a bucket somewhere, you need to store a reference to that file in your database, and how those files actually get consumed after that is entirely use-case driven.

For this project, I wanted to build a media library: a way to upload, organize, and serve images and videos. Serving straight from the bucket is an option, but it's slow for users far from your bucket's region, and wasteful if you're on AWS or Google Cloud pricing. The standard solution is a CDN — you connect it to your bucket, and it automatically caches files close to your users. What's made this genuinely affordable in recent years is CDNs that also handle on-the-fly resizing. Instead of storing a thumbnail, a medium, and a full-size version of every image, you store one copy and let the CDN resize it via a URL parameter. Instagram does exactly this — photos are uploaded at full resolution, but a mobile feed might request a 320px crop while a desktop viewer gets something closer to 1080px. One source file, sized for whoever's asking.

When I first started getting comfortable with programming and had this lifecycle explained to me, I was surprised at how much was hiding behind a simple HTML form with a title, a description, and a file input.

Even someone new to the industry would be surprised to find entire SaaS companies built around just this problem. On the lower end, libraries like Uppy, FilePond, or Dropzone handle the UI complexity. If you want resumable uploads, Transloadit offers a managed service built around the TUS resumable upload protocol. If you want to abstract the whole layer and just deal with file references in your database, there's Cloudinary for images or Mux for video. These come with genuinely useful extras — offline viewing, image editing, adaptive streaming — but also a significant monthly cost increase, and honestly, most apps don't need them.

What I wanted to cover in this project: collections and organization, upload tracking without the file ever touching my server, CDN-based image serving with on-the-fly resizing, and (still in progress) video streaming and encoding at a reasonable cost. What mattered most to me was zero reliance on JS packages or SaaS products that lock you in — the stack is Cloudflare R2 for storage (accessed via the S3-compatible SDK), any SQL database, and a bit of vanilla JavaScript on the frontend. The code samples are in Go, but the concepts map directly to whatever language you're using.


Step 1: Where do files actually go?

The simple answer is: your server. User picks a file, it goes to your backend, your backend saves it somewhere. Done.

The problem is that files are big. A video is hundreds of megabytes. If every upload goes through your server, your server becomes the bottleneck. It's slow, expensive, and it doesn't scale.

The real answer — used by S3, Dropbox, Google Drive, and basically every serious app — is presigned URLs.

Instead of the file going to your server, your server generates a temporary permission slip and hands it to the browser. The browser then uploads directly to storage (in my case, Cloudflare R2). Your server never touches the bytes.

presignedReq, err := presignClient.PresignPutObject(r.Context(), &s3.PutObjectInput{
    Bucket: aws.String("my-bucket"),
    Key:    aws.String(r2Key),
}, func(opts *s3.PresignOptions) {
    opts.Expires = time.Hour
})

That URL is only valid for one hour. The browser uploads directly to R2 — your server never sees the bytes. Once the PUT succeeds, the browser calls a /complete endpoint on your server with the ETag, and that's when you record it in the database.

On the client side, this is a plain XHR PUT to the presigned URL:

xhr.open('PUT', presignedUrl);
xhr.send(file);

And once the upload finishes, the browser notifies your server:

xhr.addEventListener('load', async function () {
    var etag = xhr.getResponseHeader('ETag').replace(/"/g, '');
    await fetch('/uploads/' + jobId + '/files/' + fileId + '/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ etag: etag })
    });
});

No multipart form, no file streaming through your backend — just a PUT directly to storage, then a small JSON ping to your server.


Step 2: Uploads fail. You need to track them.

AI-generated code treats an upload as one thing: it either works or it doesn't. Real uploads are messier. They stall halfway. The connection drops. The user closes the tab.

So I built a two-table tracking system: an upload job (one per batch of files) and upload files (one per file).

type uploadJob struct {
    TotalFiles     int
    CompletedFiles int
    FailedFiles    int
    Status         string  // "pending" | "uploading" | "completed"
}

Each file moves through its own lifecycle: pending → uploading → completed (or failed). When the last file completes, the job flips to completed — but that counter increment has to be atomic, otherwise two files finishing at the same time could both think they were last and double-update.

That's what a database transaction is for:

func (d *db) completeFile(ctx context.Context, fileID uuid.UUID, etag string) error {
    tx, err := d.pool.Begin(ctx)
    defer tx.Rollback(ctx)

    // mark file done, increment job counter in one atomic operation
    if completedFiles >= totalFiles {
        // mark whole job completed
    }
    return tx.Commit(ctx)
}

Step 3: Collections and slugs

A flat pile of files isn't a media library — you need organization. I added collections: named groups you can add files to, reorder, publish or unpublish.

Each collection gets a URL-friendly slug auto-generated from its title. "Summer Camp 2024" becomes summer-camp-2024. But what if that slug already exists, there are way fancier ways to solve this but for now a simple collision loop does the job.

slug := toSlug(title)
baseSlug := slug
for i := 2; ; i++ {
    exists, _ := h.db.collectionSlugExists(ctx, slug)
    if !exists {
        break
    }
    slug = fmt.Sprintf("%s-%d", baseSlug, i)
}

So you get summer-camp-2024, then summer-camp-2024-2, then summer-camp-2024-3. Simple.

Reordering media inside a collection is just an integer display_order on the join table. The UI sends the new order as JSON, the server updates each row. Drag and drop looks magical in the browser — underneath it's a loop updating numbers in a database.


Step 4: Serving images fast

I've been a fan of Bunny.net for a while — their Magic Containers service lets you connect an external bucket (like R2) and get CDN delivery plus on-the-fly image processing without migrating your files. Files live in Cloudflare R2, served through BunnyCDN. The trick is on-the-fly resizing via a URL parameter:

func (i collectionImage) CDNUrl(width int) string {
    return fmt.Sprintf("https://my-cdn.b-cdn.net/%s?width=%d", i.R2Key, width)
}

Call CDNUrl(400) for a thumbnail, CDNUrl(1200) for full-size. The CDN handles the actual resize — no image processing library on your server, no storing multiple sizes. The CDN just does it.