← Back to projects

Writeflow - Serverless CMS

React TypeScript Tiptap AWS SAM Lambda API Gateway DynamoDB S3 Cognito CloudFront

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:3000 as 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_URL exists in .env, use that value
  • If not, use http://localhost:3000 as 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.yaml with ApiKeyRequired: true
  • Automatically creates a Usage Plan with limits:
    • Rate Limit: 50 requests/second
    • Burst Limit: 100 requests
  • All requests must include the x-api-key header

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 isValid state (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: false in the store
  • This causes the dialog to show again to request a new key
  • If the request succeeds, marks isValid: true

Complete flow:

  1. User opens the app for the first time
  2. No API key in localStorage → Dialog is shown
  3. User enters the key generated with the script
  4. Key is saved in localStorage
  5. First request to backend includes the x-api-key header
  6. If valid → marks as valid and everything works
  7. 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:

Frontend Layer React App (S3 + CloudFlare CDN) Authentication Amazon Cognito (User Pools + JWT) API Gateway + Lambda Functions POST /posts PUT /posts/{slug} GET /posts (list all) GET /posts/{slug} DELETE /posts/{slug} Data Layer DynamoDB (metadata) S3 Bucket (HTML)

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?

  1. Direct URL mapping: GET /posts/my-first-post queries DynamoDB directly without UUID conversion
  2. Guaranteed uniqueness: DynamoDB prevents duplicate PKs
  3. 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:

  1. Frontend requests a presigned URL (POST /upload-url)
  2. Backend generates URL with 5-minute validity
  3. Frontend uploads directly to S3 (bypasses Lambda)
  4. 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:

  1. Reactive: On 401 response, refresh and retry
  2. 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:

EditorProsCons
TiptapExcellent docs, React-first, extensibleLearning curve
LexicalMeta-backed, cross-platformNot yet 1.0, less mature
SlateHighly customizableSparse documentation
QuillSimple, battle-testedLess 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:

ServiceEstimated 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:

ToolProsCons
HurlPlain text files, Git-friendly, fast (Rust + libcurl), CI-native output (JUnit, TAP)CLI-only, no GUI
PostmanVisual interface, team collaborationCloud-based, requires account, proprietary format [5]
BrunoLocal files, offline-first, GUINewer ecosystem, less CI tooling
HTTPieHuman-friendly syntax, great for debuggingNo native test assertions [6]

Why Hurl?

  1. Plain text format: .hurl files 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.

  2. 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.

  3. 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.

  4. 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

IssueImpactReference
CORS allows all originsAny domain can make authenticated requests. Security misconfiguration per OWASP Top 10 [10]Access-Control-Allow-Origin: '*' in template.yaml
No backend HTML sanitizationFrontend sanitizes with DOMPurify, but API trusts input blindly. Defense in depth violatedcreatePost.ts accepts raw HTML
No DynamoDB backupsData loss risk. Point-in-Time Recovery not enabled [11]Missing PointInTimeRecoverySpecification
No CI/CD pipelineManual sam deploy, no automated tests before productionNo 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

  1. Use React Query from the start: Custom hooks (use-posts.ts) reimplement much of what React Query offers (caching, refetch, loading states)

  2. 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

  3. Schema-first API: Define complete OpenAPI spec before implementing. The current openapi.yaml was created after the code

  4. 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