Profile picture

Arkadiusz Kulpa

AI & ML Engineer

HomeAboutBlogChat
Profile picture

Arkadiusz Kulpa

AI & ML Engineer

HomeAboutBlogChat

Building a Media Upload Pipeline with S3 Presigned URLs and CDN Delivery

2026-03-055 min read
s3cdngraphqllambdamedia-uploadawsnextjsdevlog

Building a Media Upload Pipeline with S3 Presigned URLs and CDN Delivery

A blog without images is just a wall of text. After building the CMS and REST API, writing and publishing posts was smooth. But every time I wanted to include an architecture diagram, a screenshot, or a PDF, I had to manually upload it to S3 through the AWS console and construct the URL by hand.

That friction needed to go. So I built a proper media upload pipeline — presigned URLs for secure uploads, CDN delivery for fast loading, and custom React components for rendering.

The Presigned URL Pattern

The upload flow never sends the file through my server. Instead, the server generates a time-limited URL that allows the browser to upload directly to S3:

Browser → POST /api/media/ → Server generates presigned URL → Returns URL to browser
Browser → PUT <presigned URL> → File goes directly to S3

This pattern has several advantages:

  • No server bandwidth — the file goes straight from the browser to S3
  • No file size limits on the server — S3 handles files up to 5TB
  • Time-limited security — the presigned URL expires after 1 hour
  • Signed with IAM — only the server can generate valid upload URLs

The GraphQL Mutation

The media upload starts with a GraphQL mutation that calls the article-manager Lambda:

// Request
{
  slug: "my-post-slug",
  filename: "architecture-diagram.png",
  contentType: "image/png",
  mediaType: "image"
}

// Response
{
  uploadUrl: "https://s3.eu-west-2.amazonaws.com/...?X-Amz-Signature=...",
  s3Key: "media/images/my-post-slug/architecture-diagram.png"
}

The Lambda function uses @aws-sdk/s3-request-presigner to generate the presigned URL. The S3 key follows a consistent pattern: media/{type}/{slug}/{filename}, which keeps media organised by post and type.

The S3 Upload

Once the browser has the presigned URL, it performs a simple PUT request with the file content:

await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': contentType },
  body: file,
});

No authentication headers needed — the presigned URL contains the credentials as query parameters. The file lands in S3 exactly where the server specified.

CDN Delivery

Uploading to S3 is half the story. Serving media to readers needs to be fast, regardless of their location. I configured CloudFront as a CDN layer in front of the S3 bucket.

When a reader loads a blog post with images, the requests go to CloudFront. On the first request, CloudFront fetches the image from S3 and caches it at the edge location closest to the reader. Subsequent requests for the same image are served from cache with minimal latency.

The CDN URL is constructed from the S3 key using a utility function:

// lib/media-url.ts
export function getMediaUrl(s3Key: string): string {
  return `${CDN_BASE_URL}/${s3Key}`;
}

This abstraction means that if I ever change the CDN configuration or switch providers, there is a single function to update.

Custom Blog Components

Raw image tags and iframes are not good enough for a technical blog. I built two custom components that can be used directly in markdown content.

<blogimage> — Responsive Images with Zoom

<blogimage
  src="media/images/my-post/diagram.png"
  alt="Architecture diagram"
  caption="System architecture overview"
  width="900"
  height="500"></blogimage>

The blogimage component:

  • Renders as a responsive image that scales to the container width
  • Uses the width and height attributes for Next.js image optimisation (prevents layout shift)
  • Displays an optional caption below the image
  • Opens a full-resolution modal when clicked (the image zoom feature from post 1)
  • Resolves the src through the CDN URL utility

<BlogPDF> — Embedded PDF Viewer

<BlogPDF
  src="media/pdfs/my-post/report.pdf"
  title="Technical Report"
  caption="Full analysis available below"
  width="900"
  height="700"
  downloadText="Download Report (PDF)"
/>

The BlogPDF component:

  • Embeds the PDF in an iframe with a clean viewer UI
  • Provides an "Open in New Window" button for full-screen reading
  • Optionally shows a download button with custom text
  • Falls back gracefully on mobile browsers that do not support inline PDFs

Both components are registered with rehype-raw, which allows raw HTML in the markdown content. The markdown renderer recognises the tags and replaces them with the React components at render time.

Admin Integration

The media upload is integrated into the admin editor as a media picker. When editing a post, you can:

  1. Open the media gallery to see all media already uploaded for that post
  2. Upload new files directly from the editor
  3. Click "Insert" to add the appropriate tag at the cursor position
  4. Preview the rendered media in the live preview pane

The gallery shows thumbnails for images and icons for PDFs. Each item has an "Insert" button (adds the tag to the markdown), a "Copy tag" button (copies the HTML to clipboard), and a "Download" button (opens the file in a new tab).

What This Means for You

When you see images in blog posts on this site, they are served from a CDN. The upload was handled by a presigned URL flow that never touched my server. The rendering uses custom components that provide zoom, captions, and responsive sizing. The entire pipeline — from file selection to CDN delivery — is automated through the admin UI.

What Came Next

With the media pipeline in place, the content management system was feature-complete. But the admin UI itself had a few bugs that needed attention — most notably a ghost text rendering issue in the markdown editor.

That story is in Fixing the MDEditor Ghost Text and Adding a Media Gallery Download.


This post is part of a series documenting the development of arkadiuszkulpa.co.uk.

← Back to Blog