1 / 7
Writeflow - CMS Serverless
El Problema
Los creadores de contenido necesitan herramientas de publicación simples sin la complejidad de las plataformas CMS tradicionales. La mayoría de las soluciones son demasiado complejas o requieren infraestructura costosa de mantener.
Mi Rol
Desarrollador Full-Stack - Diseñé la arquitectura serverless, implementé la integración del editor y construí todo el backend en AWS usando Infraestructura como Código.
Contexto
Como desarrollador que crea contenido para mis canales de YouTube y blog, experimenté de primera mano la fricción de las soluciones CMS existentes. WordPress se sentía pesado—los temas cargados con constructores visuales, sliders y scripts integrados pueden destruir el rendimiento incluso antes de agregar contenido [1]. Las plataformas CMS headless requerían configuraciones complejas con planes gratuitos limitados: el plan gratuito de Contentful tiene un límite de 25K registros y 100K llamadas API/mes [2], y su plan Team comienza en $489/mes [3]. Incluso Sanity, conocido por sus límites generosos, salta a $99/mes para equipos [4].
Decidí construir Writeflow: un CMS minimal y serverless que se enfoca en la experiencia de escritura mientras aprovecha el modelo de pago por uso de AWS para mantener los costos cercanos a cero para uso a pequeña escala.
Configuración del Proyecto
Variables de Entorno
El proyecto utiliza variables de entorno para configurar la URL del backend. El flujo de configuración:
Archivo base: .env.example
El repositorio incluye un archivo .env.example en el directorio app/ que sirve como plantilla:
- Contiene
VITE_API_URL=http://localhost:3000como valor por defecto para desarrollo local - Incluye comentarios con ejemplos de URLs de producción
- Nota sobre el API Key requerido
Creación del archivo .env:
El archivo .env NO está en el repositorio (está en .gitignore). Cada usuario debe crearlo localmente:
cp .env.example .env
Uso en el código:
// src/services/api.ts, línea 24
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
- Si existe
VITE_API_URLen el.env, usa ese valor - Si no existe, usa
http://localhost:3000como fallback
Para obtener la URL real del backend:
# Ejecutar en el directorio del backend
aws cloudformation describe-stacks \
--stack-name writeflow-sam-app \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text
Esto devuelve algo como: https://wcbkhbiuqk.execute-api.us-east-1.amazonaws.com/dev
Esa URL se coloca en el archivo .env:
VITE_API_URL=https://wcbkhbiuqk.execute-api.us-east-1.amazonaws.com/dev
Sistema de API Key
El backend implementa rate limiting mediante API Keys de AWS API Gateway. El frontend necesita enviar una API key válida en cada petición.
Arquitectura del sistema:
1. Backend (AWS API Gateway):
- Configurado en
template.yamlconApiKeyRequired: true - Crea automáticamente un Usage Plan con límites:
- Rate Limit: 50 requests/segundo
- Burst Limit: 100 requests
- Todas las peticiones deben incluir el header
x-api-key
2. Generación de API Keys:
El script backend/writeflow-sam-app/scripts/create-api-key.sh:
- Busca el Usage Plan del stack
- Crea una nueva API Key en AWS
- La asocia al Usage Plan
- Devuelve el valor de la key (ej:
JuLH47yXFV8wyIPkGw4Pv33j0MLtvSMY5qEMABnS)
./scripts/create-api-key.sh "nombre-descriptivo" dev
3. Frontend - Almacenamiento (Store):
En src/store/api-key.ts hay un Zustand store con persistencia en localStorage:
- Almacena la API key ingresada por el usuario
- Mantiene un estado
isValid(null/true/false) para saber si la key funciona - Se persiste automáticamente en localStorage con la key
writeflow-api-key
4. Frontend - Diálogo de entrada (ApiKeyDialog):
En src/components/ApiKeyDialog.tsx:
- Muestra un diálogo modal cuando NO hay API key o cuando fue marcada como inválida
- El diálogo NO se puede cerrar (sin escape/click afuera) - es obligatorio
- Tiene un input tipo password para ingresar la key
- Al guardar, actualiza el store y cierra el diálogo
5. Frontend - Inyección en requests:
// src/services/api.ts, línea 139
headers: {
...(apiKey ? { "x-api-key": apiKey } : {}),
...
}
Automáticamente inyecta el header x-api-key en TODAS las peticiones si existe una key.
6. Validación automática:
// Líneas 181-185 de api.ts
if (response.status === 403) {
// API key inválida
useApiKeyStore.getState().setIsValid(false);
}
- Si una petición retorna 403 Forbidden, significa que la API key es inválida
- Automáticamente marca
isValid: falseen el store - Esto hace que el diálogo se vuelva a mostrar para pedir una nueva key
- Si la petición es exitosa, marca
isValid: true
Flujo completo:
- Usuario abre la app por primera vez
- No hay API key en localStorage → Se muestra el diálogo
- Usuario ingresa la key generada con el script
- Key se guarda en localStorage
- Primera petición al backend incluye el header
x-api-key - Si es válida → marca como valid y funciona todo
- Si es inválida → marca como invalid y vuelve a pedir la key
Para demo pública: “Para usar esta demo de Writeflow, necesitas una API key. Esta medida implementa rate limiting para proteger el backend. La key se guarda localmente en tu navegador y se envía en cada petición. Si no tienes acceso a generar keys, contacta al administrador. La key nunca expira pero puede ser revocada desde AWS.”
Vista General de Arquitectura
El sistema está construido enteramente sobre servicios serverless de AWS, definido en un solo archivo template.yaml (~586 líneas) usando AWS SAM:
Componentes principales:
- 13 funciones Lambda manejando 15 endpoints de API
- API Gateway con Cognito Authorizer para validación de JWT
- DynamoDB para metadata de posts (capacidad on-demand)
- S3 para almacenamiento de contenido HTML con acceso público de lectura
- Cognito User Pool para autenticación
Organización de Handlers
Cada función Lambda vive en su propio archivo, con utilidades compartidas extraídas:
backend/writeflow-sam-app/src/
├── handlers/ # 1 archivo = 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 # Interfaz Post
└── utils/
├── db.ts # Singleton del cliente DynamoDB
├── s3.ts # Helpers de S3 (generateContentKey, deleteContent)
├── slug.ts # generateSlug, ensureUniqueSlug
└── sanitize.ts # DOMPurify para HTML
Cada bundle está optimizado via esbuild con tree-shaking, resultando en ~50-100KB por función:
# template.yaml
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2022
Sourcemap: true
EntryPoints:
- handlers/createPost.ts
Inmersiones Técnicas
Diseño del Schema de DynamoDB
Elegí una estructura deliberadamente simple y desnormalizada:
# template.yaml
PostsTable:
AttributeDefinitions:
- AttributeName: slug # Partition key
- AttributeName: authorId # Para GSI
- AttributeName: status # Para GSI
KeySchema:
- AttributeName: slug
KeyType: HASH
BillingMode: PAY_PER_REQUEST # On-demand
GlobalSecondaryIndexes:
- IndexName: author-index # Listar posts por autor
- IndexName: status-index # Listar posts publicados
¿Por qué slug como partition key?
- Mapeo directo de URL:
GET /posts/mi-primer-postconsulta DynamoDB directamente sin conversión de UUID - Unicidad garantizada: DynamoDB previene PKs duplicados
- Simplicidad: No hay necesidad de mantener mapeos slug↔UUID
Trade-off aceptado: Cambiar un título NO cambia el slug. Esto previene links rotos pero significa que las URLs pueden no reflejar los títulos actuales. Si necesitas un nuevo slug, crea un post nuevo y borra el viejo.
// 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}`; // "mi-post-2" si "mi-post" existe
counter++;
}
return slug;
}
Almacenamiento de Contenido con Presigned URLs
El contenido del post (HTML) se almacena en S3, no en DynamoDB. El flujo:
- Frontend solicita una URL prefirmada (
POST /upload-url) - Backend genera URL con validez de 5 minutos
- Frontend sube directamente a S3 (bypasea Lambda)
- Backend solo almacena la referencia de la key de S3
// 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 minutos
});
Validación de seguridad en creación de post:
// src/handlers/createPost.ts
const expectedPrefix = `posts/${authorId}/`;
if (!input.contentKey.startsWith(expectedPrefix)) {
return errorResponse('Invalid content key', 403);
}
Esto previene que usuarios reclamen propiedad de contenido subido por otros.
Caso edge: Si un usuario obtiene una URL de subida pero nunca sube, el post se crea con un contentKey apuntando a nada. S3 retorna 404, y el frontend muestra contenido vacío. Actualmente no hay cleanup automático—una mejora futura agregaría S3 Lifecycle Rules para objetos huérfanos.
Cleanup al borrar sí está implementado:
// src/handlers/deletePost.ts
await deleteContent(existingPost.contentKey); // Borrar de S3
await docClient.send(new DeleteCommand({ ... })); // Borrar de DynamoDB
Flujo de Autenticación
Cognito maneja el trabajo pesado, pero la implementación tiene matices:
ID Token vs Access Token:
// app/src/store/auth.ts
// Backend usa ID Token (contiene claims del usuario como email, sub)
// NO Access Token (solo contiene scopes de OAuth)
idToken: string | null; // Usado en header Authorization
Estrategia dual de refresh:
- Reactivo: Ante respuesta 401, refrescar y reintentar
- Proactivo: Timer refresca 5 minutos antes de expiración
// app/src/services/api.ts - Refresh reactivo
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 - Refresh proactivo
useEffect(() => {
const timeUntilExpiry = expiresAt - Date.now();
const refreshTime = timeUntilExpiry - (5 * 60 * 1000); // 5 min antes
const timer = setTimeout(refreshToken, refreshTime);
return () => clearTimeout(timer);
}, [expiresAt]);
Limitación conocida: Cognito retorna refreshToken en el body de la respuesta, no como cookie httpOnly. Lo almacenamos en localStorage—menos seguro pero funcional para MVP.
Editor de Texto Enriquecido
Tiptap fue elegido después de evaluar alternativas:
| Editor | Pros | Contras |
|---|---|---|
| Tiptap | Excelente documentación, React-first, extensible | Curva de aprendizaje |
| Lexical | Respaldado por Meta, multiplataforma | Aún no es 1.0, menos maduro |
| Slate | Altamente personalizable | Documentación escasa |
| Quill | Simple, probado en batalla | Menos flexible |
El editor usa guardado por lotes (no sync en tiempo real):
// app/src/components/Editor/index.tsx
onUpdate: ({ editor }) => {
const rawHTML = editor.getHTML();
const sanitized = sanitizeHTML(rawHTML);
setContent(rawHTML);
onContentChange?.(rawHTML, sanitized);
};
El guardado ocurre en acción explícita del usuario (clic en “Guardar” o “Publicar”), no en cada keystroke. Esto simplifica la arquitectura y evita costos de escrituras frecuentes a S3/DynamoDB.
Prevención de XSS:
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']
});
Análisis de Costos
Para un MVP con ~1,000 requests/mes:
| Servicio | Costo Estimado |
|---|---|
| Lambda | ~$0.00 (free tier: 1M requests/mes) |
| API Gateway | ~$0.00 (free tier: 1M requests/mes) |
| DynamoDB | ~$0.00 (free tier: 25 WCU/RCU) |
| S3 | ~$0.01 (almacenamiento) |
| Cognito | ~$0.00 (free tier: 50,000 MAU) |
| CloudWatch Logs | ~$0.50 |
| Total | ~$0.50-1.00/mes |
Con tráfico real (~10K requests/día):
- Lambda: ~$2/mes
- API Gateway: ~$3.50/mes
- DynamoDB: ~$1/mes (on-demand)
- S3: ~$0.50/mes
- Total: ~$7-10/mes
El costo escala casi linealmente hasta ~100K usuarios, cuando optimizaciones como capacidad provisionada de DynamoDB y caching de API Gateway valen la pena.
Testing con Hurl
Para tests E2E de API, evalué varias opciones:
| Herramienta | Pros | Contras |
|---|---|---|
| Hurl | Archivos de texto plano, Git-friendly, rápido (Rust + libcurl), output nativo para CI (JUnit, TAP) | Solo CLI, sin GUI |
| Postman | Interfaz visual, colaboración en equipo | Basado en la nube, requiere cuenta, formato propietario [5] |
| Bruno | Archivos locales, offline-first, GUI | Ecosistema más nuevo, menos tooling para CI |
| HTTPie | Sintaxis human-friendly, excelente para debugging | Sin assertions nativas para tests [6] |
¿Por qué Hurl?
-
Formato de texto plano: Los archivos
.hurlson legibles, pueden versionarse en Git, y sirven como documentación [7]. Miembros no técnicos del equipo pueden leer y entender los tests sin aprender una sintaxis compleja. -
Performance: Escrito en Rust y potenciado por libcurl, Hurl corre sin la latencia de inicio común en herramientas basadas en Node [8]. Esto importa cuando corres cientos de tests en CI.
-
Nativo para CI/CD: Genera formatos JUnit y TAP out of the box, integrándose seamlessly con GitLab CI y GitHub Actions [9]. No necesita plugins ni adaptadores.
-
Confiabilidad: Trabaja con datos HTTP raw sin navegador headless, resultando en tasas muy bajas de falsos positivos comparado con tests estilo Selenium [7].
Trade-off aceptado: Sin GUI significa que el testing exploratorio se hace con curl o Bruno. Hurl es estrictamente para tests automatizados de regresión.
# 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"
Ejecutar con:
hurl --test --variables-file vars/dev.env \
posts/create-post.hurl \
posts/update-post.hurl
Para integración con CI (GitHub Actions):
- name: Run API tests
run: |
hurl --test --report-junit results.xml integration/*.hurl
Limitaciones Actuales
Una evaluación honesta de lo que le falta a la aplicación:
Problemas Críticos
| Problema | Impacto | Referencia |
|---|---|---|
| CORS permite todos los orígenes | Cualquier dominio puede hacer requests autenticados. Misconfiguration de seguridad según OWASP Top 10 [10] | Access-Control-Allow-Origin: '*' en template.yaml |
| Sin sanitización de HTML en backend | Frontend sanitiza con DOMPurify, pero la API confía ciegamente en el input. Defensa en profundidad violada | createPost.ts acepta HTML raw |
| Sin backups de DynamoDB | Riesgo de pérdida de datos. Point-in-Time Recovery no habilitado [11] | Falta PointInTimeRecoverySpecification |
| Sin pipeline CI/CD | sam deploy manual, sin tests automatizados antes de producción | Sin GitHub Actions ni CodePipeline |
Gaps Moderados
Frontend:
- Sin sistema i18n (solo un idioma)
- Sin meta tags SEO ni Open Graph
- Sin analytics ni tracking de uso
- Sin auto-guardado (usuarios pierden trabajo si cierran pestaña)
- Sin búsqueda en blog público
- Sin comentarios ni engagement de lectores
- Sin tags/categorías para organización
Backend:
- Sin rate limiting (vulnerable a abuso)
- Sin soft deletes (sin recuperación de posts borrados)
- Sin logging de auditoría
- Solo emails genéricos de Cognito (sin templates custom)
- Sin headers de cache (cada request va a DynamoDB)
Infraestructura:
- Sin CDN (CloudFront no configurado)
- Sin protección WAF
- Sin Dead Letter Queue para operaciones async fallidas
Mejoras Futuras
Para información detallada sobre las funcionalidades y mejoras planificadas, consulta el Roadmap en GitHub.
Qué Haría Diferente
-
Usar React Query desde el inicio: Los hooks custom (
use-posts.ts) reimplementan mucho de lo que React Query ofrece (caching, refetch, estados de loading) -
Considerar Cloudflare Workers: Para un blog, la latencia edge de Cloudflare sería mejor que Lambda regional. El vendor lock-in de Cognito + DynamoDB no compensa para este caso de uso
-
API schema-first: Definir especificación OpenAPI completa antes de implementar. El
openapi.yamlactual fue creado después del código -
Tests desde el día 1: Los tests E2E con Hurl se agregaron tarde. Tener tests de integración temprano hubiera acelerado el desarrollo
Resumen del Tech Stack
Frontend:
- React 19 + TypeScript
- Vite 7 + SWC
- TipTap (editor)
- Zustand (estado)
- shadcn/ui + Tailwind v4
- Biome (lint/format)
Backend:
- AWS SAM + Lambda (Node 22)
- API Gateway + Cognito Authorizer
- DynamoDB (metadata de posts)
- S3 (contenido HTML)
- Cognito (auth)
Testing:
- Hurl (tests E2E de API)
Referencias
[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