1 / 7
Writeflow - Serverless CMS
The Problem
Content creators need simple publishing tools without the overhead of traditional CMS platforms. Most solutions are either too complex or require expensive infrastructure to maintain.
My Role
Full-Stack Developer - Designed the serverless architecture, implemented the editor integration, and built the entire AWS backend using Infrastructure as Code.
Context
As a developer creating content for my YouTube channels and blog, I experienced firsthand the friction of existing CMS solutions. WordPress felt bloated—themes heavy with visual builders, sliders, and built-in scripts can destroy performance even before adding content [1]. Headless CMS platforms required complex setups with limited free tiers: Contentful’s free tier caps at 25K records and 100K API calls/month [2], and their Team plan starts at $489/month [3]. Even Sanity, known for generous limits, jumps to $99/month for teams [4].
I decided to build Writeflow: a minimal, serverless CMS that focuses on the writing experience while leveraging AWS’s pay-per-use model to keep costs near zero for small-scale usage.
Project Configuration
Environment Variables
The project uses environment variables to configure the backend URL. The configuration flow:
Base file: .env.example
The repository includes a .env.example file in the app/ directory that serves as a template:
- Contains
VITE_API_URL=http://localhost:3000as the default value for local development - Includes comments with production URL examples
- Note about the required API Key
Creating the .env file:
The .env file is NOT in the repository (it’s in .gitignore). Each user must create it locally:
cp .env.example .env
Usage in code:
// src/services/api.ts, line 24
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
- If
VITE_API_URLexists in.env, use that value - If not, use
http://localhost:3000as fallback
To get the real backend URL:
# Run in the backend directory
aws cloudformation describe-stacks \
--stack-name writeflow-sam-app \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text
This returns something like: https://wcbkhbiuqk.execute-api.us-east-1.amazonaws.com/dev
That URL goes into the .env file:
VITE_API_URL=https://wcbkhbiuqk.execute-api.us-east-1.amazonaws.com/dev
API Key System
The backend implements rate limiting via AWS API Gateway API Keys. The frontend needs to send a valid API key with every request.
System architecture:
1. Backend (AWS API Gateway):
- Configured in
template.yamlwithApiKeyRequired: true - Automatically creates a Usage Plan with limits:
- Rate Limit: 50 requests/second
- Burst Limit: 100 requests
- All requests must include the
x-api-keyheader
2. API Key Generation:
The backend/writeflow-sam-app/scripts/create-api-key.sh script:
- Searches for the stack’s Usage Plan
- Creates a new API Key in AWS
- Associates it with the Usage Plan
- Returns the key value (e.g.,
JuLH47yXFV8wyIPkGw4Pv33j0MLtvSMY5qEMABnS)
./scripts/create-api-key.sh "descriptive-name" dev
3. Frontend - Storage (Store):
In src/store/api-key.ts there’s a Zustand store with localStorage persistence:
- Stores the API key entered by the user
- Maintains an
isValidstate (null/true/false) to know if the key works - Automatically persists in localStorage with the key
writeflow-api-key
4. Frontend - Input Dialog (ApiKeyDialog):
In src/components/ApiKeyDialog.tsx:
- Shows a modal dialog when there’s NO API key or when it was marked as invalid
- The dialog CANNOT be closed (no escape/click outside) - it’s mandatory
- Has a password-type input to enter the key
- On save, updates the store and closes the dialog
5. Frontend - Request Injection:
// src/services/api.ts, line 139
headers: {
...(apiKey ? { "x-api-key": apiKey } : {}),
...
}
Automatically injects the x-api-key header in ALL requests if a key exists.
6. Automatic Validation:
// Lines 181-185 of api.ts
if (response.status === 403) {
// Invalid API key
useApiKeyStore.getState().setIsValid(false);
}
- If a request returns 403 Forbidden, it means the API key is invalid
- Automatically marks
isValid: falsein the store - This causes the dialog to show again to request a new key
- If the request succeeds, marks
isValid: true
Complete flow:
- User opens the app for the first time
- No API key in localStorage → Dialog is shown
- User enters the key generated with the script
- Key is saved in localStorage
- First request to backend includes the
x-api-keyheader - If valid → marks as valid and everything works
- If invalid → marks as invalid and asks for the key again
For public demo: “To use this Writeflow demo, you need an API key. This measure implements rate limiting to protect the backend. The key is stored locally in your browser and sent with each request. If you don’t have access to generate keys, contact the administrator. The key never expires but can be revoked from AWS.”
Architecture Overview
The system is built entirely on AWS serverless services, defined in a single template.yaml file (~586 lines) using AWS SAM:
Key components:
- 13 Lambda functions handling 15 API endpoints
- API Gateway with Cognito Authorizer for JWT validation
- DynamoDB for post metadata (on-demand capacity)
- S3 for HTML content storage with public read access
- Cognito User Pool for authentication
Handler Organization
Each Lambda function lives in its own file, with shared utilities extracted:
backend/writeflow-sam-app/src/
├── handlers/ # 1 file = 1 Lambda
│ ├── createPost.ts
│ ├── getPost.ts
│ ├── updatePost.ts
│ ├── deletePost.ts
│ ├── listPosts.ts
│ ├── getUploadUrl.ts
│ └── auth/
│ ├── login.ts
│ ├── register.ts
│ ├── confirm.ts
│ ├── refreshToken.ts
│ ├── resendCode.ts
│ ├── forgotPassword.ts
│ └── resetPassword.ts
├── types/
│ ├── api.ts # Response helpers, AuthenticatedEvent
│ └── post.ts # Post interface
└── utils/
├── db.ts # DynamoDB client singleton
├── s3.ts # S3 helpers (generateContentKey, deleteContent)
├── slug.ts # generateSlug, ensureUniqueSlug
└── sanitize.ts # DOMPurify for HTML
Each bundle is optimized via esbuild with tree-shaking, resulting in ~50-100KB per function:
# template.yaml
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2022
Sourcemap: true
EntryPoints:
- handlers/createPost.ts
Technical Deep Dives
DynamoDB Schema Design
I chose a deliberately simple, denormalized structure:
# template.yaml
PostsTable:
AttributeDefinitions:
- AttributeName: slug # Partition key
- AttributeName: authorId # For GSI
- AttributeName: status # For GSI
KeySchema:
- AttributeName: slug
KeyType: HASH
BillingMode: PAY_PER_REQUEST # On-demand
GlobalSecondaryIndexes:
- IndexName: author-index # List posts by author
- IndexName: status-index # List published posts
Why slug as partition key?
- Direct URL mapping:
GET /posts/my-first-postqueries DynamoDB directly without UUID conversion - Guaranteed uniqueness: DynamoDB prevents duplicate PKs
- Simplicity: No need to maintain slug↔UUID mappings
Trade-off accepted: Changing a title does NOT change the slug. This prevents broken links but means URLs might not reflect current titles. If you need a new slug, create a new post and delete the old one.
// src/utils/slug.ts
export async function ensureUniqueSlug(
docClient: DynamoDBDocumentClient,
tableName: string,
baseSlug: string
): Promise<string> {
let slug = baseSlug;
let counter = 1;
while (await slugExists(docClient, tableName, slug)) {
slug = `${baseSlug}-${counter}`; // "my-post-2" if "my-post" exists
counter++;
}
return slug;
}
Content Storage with Presigned URLs
Post content (HTML) is stored in S3, not DynamoDB. The flow:
- Frontend requests a presigned URL (
POST /upload-url) - Backend generates URL with 5-minute validity
- Frontend uploads directly to S3 (bypasses Lambda)
- Backend only stores the S3 key reference
// src/handlers/getUploadUrl.ts
const command = new PutObjectCommand({
Bucket: CONTENT_BUCKET,
Key: contentKey, // posts/{authorId}/{slug}.html
ContentType: 'text/html',
});
const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300, // 5 minutes
});
Security validation on post creation:
// src/handlers/createPost.ts
const expectedPrefix = `posts/${authorId}/`;
if (!input.contentKey.startsWith(expectedPrefix)) {
return errorResponse('Invalid content key', 403);
}
This prevents users from claiming ownership of content uploaded by others.
Edge case: If a user gets an upload URL but never uploads, the post is created with a contentKey pointing to nothing. S3 returns 404, and the frontend shows empty content. Currently no automatic cleanup—a future improvement would add S3 Lifecycle Rules for orphaned objects.
Cleanup on delete is implemented:
// src/handlers/deletePost.ts
await deleteContent(existingPost.contentKey); // Delete from S3
await docClient.send(new DeleteCommand({ ... })); // Delete from DynamoDB
Authentication Flow
Cognito handles the heavy lifting, but the implementation has nuances:
ID Token vs Access Token:
// app/src/store/auth.ts
// Backend uses ID Token (contains user claims like email, sub)
// NOT Access Token (only contains OAuth scopes)
idToken: string | null; // Used in Authorization header
Dual refresh strategy:
- Reactive: On 401 response, refresh and retry
- Proactive: Timer refreshes 5 minutes before expiration
// app/src/services/api.ts - Reactive refresh
if (response.status === 401 && !skipAuth) {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshToken();
}
const refreshed = await refreshPromise;
if (refreshed) {
response = await fetch(url, { ...config, Authorization: newToken });
}
}
// app/src/hooks/use-token-refresh.ts - Proactive refresh
useEffect(() => {
const timeUntilExpiry = expiresAt - Date.now();
const refreshTime = timeUntilExpiry - (5 * 60 * 1000); // 5 min before
const timer = setTimeout(refreshToken, refreshTime);
return () => clearTimeout(timer);
}, [expiresAt]);
Known limitation: Cognito returns refreshToken in the response body, not as an httpOnly cookie. We store it in localStorage—less secure but functional for MVP.
Rich Text Editor
Tiptap was chosen after evaluating alternatives:
| Editor | Pros | Cons |
|---|---|---|
| Tiptap | Excellent docs, React-first, extensible | Learning curve |
| Lexical | Meta-backed, cross-platform | Not yet 1.0, less mature |
| Slate | Highly customizable | Sparse documentation |
| Quill | Simple, battle-tested | Less flexible |
The editor uses batch save (not real-time sync):
// app/src/components/Editor/index.tsx
onUpdate: ({ editor }) => {
const rawHTML = editor.getHTML();
const sanitized = sanitizeHTML(rawHTML);
setContent(rawHTML);
onContentChange?.(rawHTML, sanitized);
};
Saving happens on explicit user action (click “Save” or “Publish”), not on every keystroke. This simplifies architecture and avoids costs from frequent S3/DynamoDB writes.
XSS Prevention:
import DOMPurify from 'dompurify';
const sanitizedHTML = DOMPurify.sanitize(editor.getHTML(), {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class']
});
Cost Analysis
For an MVP with ~1,000 requests/month:
| Service | Estimated Cost |
|---|---|
| Lambda | ~$0.00 (free tier: 1M requests/month) |
| API Gateway | ~$0.00 (free tier: 1M requests/month) |
| DynamoDB | ~$0.00 (free tier: 25 WCU/RCU) |
| S3 | ~$0.01 (storage) |
| Cognito | ~$0.00 (free tier: 50,000 MAU) |
| CloudWatch Logs | ~$0.50 |
| Total | ~$0.50-1.00/month |
With real traffic (~10K requests/day):
- Lambda: ~$2/month
- API Gateway: ~$3.50/month
- DynamoDB: ~$1/month (on-demand)
- S3: ~$0.50/month
- Total: ~$7-10/month
Cost scales nearly linearly until ~100K users, when optimizations like provisioned DynamoDB capacity and API Gateway caching become worthwhile.
Testing with Hurl
For E2E API tests, I evaluated several options:
| Tool | Pros | Cons |
|---|---|---|
| Hurl | Plain text files, Git-friendly, fast (Rust + libcurl), CI-native output (JUnit, TAP) | CLI-only, no GUI |
| Postman | Visual interface, team collaboration | Cloud-based, requires account, proprietary format [5] |
| Bruno | Local files, offline-first, GUI | Newer ecosystem, less CI tooling |
| HTTPie | Human-friendly syntax, great for debugging | No native test assertions [6] |
Why Hurl?
-
Plain text format:
.hurlfiles are human-readable, can be version-controlled, and serve as documentation [7]. Non-technical team members can read and understand tests without learning a complex syntax. -
Performance: Written in Rust and powered by libcurl, Hurl runs without the startup latency common in Node-based tools [8]. This matters when running hundreds of tests in CI.
-
CI/CD native: Outputs JUnit and TAP formats out of the box, integrating seamlessly with GitLab CI and GitHub Actions [9]. No plugins or adapters needed.
-
Reliability: Works on raw HTTP data without a headless browser, resulting in very low false positive rates compared to Selenium-style tests [7].
Trade-off accepted: No GUI means exploratory testing is done with curl or Bruno. Hurl is strictly for automated regression tests.
# tests/e2e/posts/create-post.hurl
POST {{base_url}}/posts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Test Post",
"contentKey": "posts/{{user_id}}/test-post.html",
"status": "draft"
}
HTTP 201
[Asserts]
jsonpath "$.slug" == "test-post"
jsonpath "$.status" == "draft"
Run with:
hurl --test --variables-file vars/dev.env \
posts/create-post.hurl \
posts/update-post.hurl
For CI integration (GitHub Actions):
- name: Run API tests
run: |
hurl --test --report-junit results.xml integration/*.hurl
Current Limitations
An honest assessment of what the application lacks:
Critical Issues
| Issue | Impact | Reference |
|---|---|---|
| CORS allows all origins | Any domain can make authenticated requests. Security misconfiguration per OWASP Top 10 [10] | Access-Control-Allow-Origin: '*' in template.yaml |
| No backend HTML sanitization | Frontend sanitizes with DOMPurify, but API trusts input blindly. Defense in depth violated | createPost.ts accepts raw HTML |
| No DynamoDB backups | Data loss risk. Point-in-Time Recovery not enabled [11] | Missing PointInTimeRecoverySpecification |
| No CI/CD pipeline | Manual sam deploy, no automated tests before production | No GitHub Actions or CodePipeline |
Moderate Gaps
Frontend:
- No i18n system (single language only)
- No SEO meta tags or Open Graph
- No analytics or usage tracking
- No auto-save (users lose work if tab closes)
- No search in public blog
- No comments or reader engagement
- No tags/categories for organization
Backend:
- No rate limiting (vulnerable to abuse)
- No soft deletes (no recovery of deleted posts)
- No audit logging
- Only generic Cognito emails (no custom templates)
- No caching headers (every request hits DynamoDB)
Infrastructure:
- No CDN (CloudFront not configured)
- No WAF protection
- No Dead Letter Queue for failed async operations
Future Improvements
For detailed information about planned features and improvements, see the Roadmap on GitHub.
What I’d Do Differently
-
Use React Query from the start: Custom hooks (
use-posts.ts) reimplement much of what React Query offers (caching, refetch, loading states) -
Consider Cloudflare Workers: For a blog, Cloudflare’s edge latency would be better than regional Lambda. The Cognito + DynamoDB vendor lock-in doesn’t pay off for this use case
-
Schema-first API: Define complete OpenAPI spec before implementing. The current
openapi.yamlwas created after the code -
Tests from day 1: Hurl E2E tests were added late. Having integration tests early would have accelerated development
Tech Stack Summary
Frontend:
- React 19 + TypeScript
- Vite 7 + SWC
- TipTap (editor)
- Zustand (state)
- shadcn/ui + Tailwind v4
- Biome (lint/format)
Backend:
- AWS SAM + Lambda (Node 22)
- API Gateway + Cognito Authorizer
- DynamoDB (posts metadata)
- S3 (HTML content)
- Cognito (auth)
Testing:
- Hurl (E2E API tests)
References
[1] WPMU DEV. Why WordPress is Slow and Bloated. wpmudev.com
[2] Monetizely. Contentful vs Strapi vs Sanity: Pricing Comparison. getmonetizely.com
[3] GroRapid Labs. Headless CMS pricing: Cost comparison. grorapidlabs.com
[4] Hygraph. Best free headless CMS platforms in 2025. hygraph.com
[5] APIs You Won’t Hate. Powerful HTTP/API Clients: Alternatives to Postman. apisyouwonthate.com
[6] Yuri Kan. HTTPie and cURL: Command-Line API Testing Tools Comparison. yrkan.com
[7] LogRocket. Exploring Hurl, a command line alternative to Postman. blog.logrocket.com
[8] Lambros Petrou. Love letter to Hurl. lambrospetrou.com
[9] GitLab. How to continuously test web apps and APIs with Hurl and GitLab CI/CD. about.gitlab.com
[10] OWASP. CORS OriginHeaderScrutiny - Security Misconfiguration. owasp.org
[11] AWS. Point-in-time recovery for DynamoDB. docs.aws.amazon.com
[12] AWS. WebSocket API in API Gateway – Real-time Communication. docs.aws.amazon.com
[13] Google. Consent management requirements for serving ads in the EEA, UK, and Switzerland. support.google.com
[14] Stigg. Best practices I wish we knew when integrating Stripe webhooks. stigg.io
[15] AWS Samples. Serverless LLM Streaming on AWS. github.com
[16] AWS. Vector engine for Amazon OpenSearch Serverless. aws.amazon.com
[17] AWS. Partitioning Pooled Multi-Tenant SaaS Data with Amazon DynamoDB. aws.amazon.com
[18] AWS. Restrictions on Lambda@Edge. docs.aws.amazon.com