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 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:
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.
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.
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.
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:
width and height attributes for Next.js image optimisation (prevents layout shift)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:
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.
The media upload is integrated into the admin editor as a media picker. When editing a post, you can:
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).
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.
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.