When I first built the blog for this portfolio, it was a collection of markdown files in a content/posts/ directory. Simple, reliable, and completely static. But after a few months of writing, the limitations became obvious.
Every new post required a code commit. Every edit meant pushing to the repository and waiting for a deployment. There was no admin interface, no way to save drafts, and no way to manage content without opening a code editor. The static approach that had served me well as a foundation was now holding me back.
So I rebuilt it. Completely.
The new blog backend separates concerns cleanly:
Admin UI (/admin) → Cognito Auth → AppSync GraphQL → DynamoDB (metadata)
→ S3 (markdown content)
→ Lambda (S3 operations)
Public readers → AppSync (API key) → DynamoDB + S3 → ISR pages
DynamoDB stores post metadata — title, slug, date, tags, status (draft/published), excerpt, reading time, and a pointer to the S3 content key. Each post is a single item in a table with GSIs on slug and status+date for efficient queries.
S3 stores the raw markdown content at articles/{slug}.md. Keeping content in S3 rather than DynamoDB means no item size limits, easy versioning, and straightforward backup.
AppSync GraphQL ties it together. The auto-generated CRUD operations from Amplify's a.model() handle metadata, while custom queries and mutations call a Lambda function for S3 operations.
Lambda (the article-manager function) handles reading, writing, and deleting markdown content from S3, plus generating presigned URLs for media uploads.
I built the admin interface at /admin, protected by Cognito authentication. Only I can access it — visitors see the published blog, I see the management dashboard.
The admin page lists all posts (published and drafts) in a table with quick actions: edit, delete, publish, unpublish. A "New Post" button opens the editor.
The editor itself uses @uiw/react-md-editor, a React component that provides a split-pane markdown editing experience — write on the left, preview on the right. The toolbar includes formatting buttons for headings, bold, italic, code blocks, and links. I added custom toolbar buttons for inserting <blogimage> and <BlogPDF> tags specific to my blog's custom components.
// PostForm.tsx — the editor component
<MDEditor
value={content}
onChange={(val) => setContent(val || '')}
height={600}
preview="live"
/>
When you hit save, the component makes two calls: one to AppSync to create/update the DynamoDB metadata, and one to the saveArticleContent mutation which triggers the Lambda to write the markdown to S3.
The admin routes are protected by Amazon Cognito using the <Authenticator> component from @aws-amplify/ui-react. When you navigate to /admin, you are prompted to sign in. The Cognito User Pool handles password policies, session management, and token refresh.
For the data layer, AppSync uses two auth modes:
This means the blog is publicly readable without any authentication overhead, while writes are fully protected.
The original blog used Static Site Generation (SSG) — pages were built at deploy time. This meant a full redeployment for every new post. With the CMS, I switched to Incremental Static Regeneration.
ISR generates pages on first request and caches them. When I publish or update a post, the admin UI calls a revalidation endpoint that tells Next.js to regenerate the affected pages:
// app/api/revalidate/route.ts
export async function POST(request: Request) {
revalidatePath('/');
revalidatePath(`/posts/${slug}/`);
return NextResponse.json({ revalidated: true });
}
The result: new posts appear on the site within seconds of publishing, without a full rebuild. Existing pages serve from cache until explicitly revalidated. The best of both worlds — static performance with dynamic content.
Moving 11 existing posts from content/posts/ to the new system required a migration script. The script reads each markdown file, parses the frontmatter with gray-matter, uploads the content to S3, and creates a BlogPost record in DynamoDB via AppSync.
// scripts/migrate-posts.ts
for (const file of postFiles) {
const { data, content } = matter(readFileSync(file, 'utf-8'));
// Upload markdown to S3
await saveArticleContent(data.slug, content);
// Create DynamoDB record
await createBlogPost({
slug: data.slug,
title: data.title,
date: data.date,
status: 'published',
s3ContentKey: `articles/${data.slug}.md`,
// ... other fields
});
}
After running the script and verifying everything rendered correctly, I removed the content/posts/ directory from the repository. The blog was fully cloud-native.
Amplify Gen2 makes complex backends approachable. Defining a DynamoDB model with GSIs, AppSync resolvers, Lambda functions, and S3 buckets — all in TypeScript — and having it deploy automatically is genuinely powerful. The a.model() API generates the GraphQL schema, resolvers, and DynamoDB tables from a few lines of code.
ISR changes the deployment model. With SSG, content changes required a deployment. With ISR, content changes require a revalidation call. This is a fundamental shift in how you think about content updates.
Building your own CMS is educational but time-consuming. I considered using a headless CMS like Contentful or Sanity. But building my own meant I understood every layer, had no external dependencies, and could extend it exactly how I wanted — which proved critical when I later added the REST API for LLM access.
The blog you are reading is served from S3 and DynamoDB. The page was generated by Next.js ISR and cached at the edge. The metadata — title, tags, reading time — comes from a DynamoDB query. The markdown content comes from an S3 object, rendered through a pipeline of remark and rehype plugins.
It is a full content management system, built from scratch, running on AWS serverless infrastructure.
With the CMS in place, I had a proper admin interface for managing blog content. But I had another idea: what if the AI assistant from post 2 could also create blog posts? That would require exposing the blog's write operations through a simple REST API.
That story is in Building a REST API So Claude Can Write My Blog Posts.
This post is part of a series documenting the development of arkadiuszkulpa.co.uk.