Building a Serverless Full-Stack App on AWS with CDK: Architecture Decisions and Tradeoffs
Introduction
Infrastructure-as-code has a reputation for complexity. But when it's done well — with a typed language, a composable abstraction layer, and clear stack boundaries — it becomes the most reliable way to build and reproduce cloud infrastructure at any scale.
This post walks through the architecture of Space Finder: a full-stack AWS application for listing and discovering venues. Every piece of infrastructure is defined in TypeScript using the AWS CDK, deployed across six stacks. I'll cover the key design decisions, the tradeoffs I made, and what I'd change at larger scale.
Architecture overview
The application has two main surfaces: a React 19 frontend served via CloudFront, and a serverless API backed by API Gateway, Lambda, and DynamoDB.
Browser
│
├── CloudFront → S3 (React SPA)
│
├── API Gateway /spaceFinder ←── Cognito JWT authorizer
│ └── Lambda (Node 20)
│ └── DynamoDB
│
├── S3 (photos) ←── direct upload via Cognito Identity Pool credentials
│
└── Cognito User Pool ←── SES for verification emails
Six CDK stacks each own a single concern: auth, database, API, storage, frontend hosting, and monitoring. This makes individual stacks deployable and testable in isolation.
Decision 1: Direct browser-to-S3 uploads
This is the decision that shaped the most of the architecture.
Lambda has a 6 MB payload limit on API Gateway-proxied requests. Routing photo uploads through the backend would mean either hitting that limit on moderately-sized images, chunking uploads in the client, or running a separate upload service. None of these are good options for a lean stack.
The alternative: after a user signs in, Amplify's fetchAuthSession() returns temporary AWS credentials from the Cognito Identity Pool. These credentials carry an IAM role that grants scoped S3 write access. The browser uploads directly to S3 using the AWS SDK v3 — the backend is never in the data path.
This eliminates unnecessary data transfer costs, removes Lambda from the failure surface for uploads, and sidesteps the payload limit entirely. The tradeoff is that the frontend carries more responsibility: it must handle S3 upload state, retries, and progress tracking.
Decision 2: Per-user data isolation at the DynamoDB layer
A common pattern for multi-tenant data isolation is to enforce access control in application logic — check the requesting user's ID against the resource owner before returning data. This works, but it's fragile. Logic drifts. Edge cases accumulate.
In Space Finder, isolation is enforced at the query layer. API Gateway validates the Cognito JWT before the request reaches Lambda. The Lambda then reads the sub claim from the authorizer context — a stable, unique identifier per user — and stamps it onto every DynamoDB write. Reads filter by it.
There's no application logic that can accidentally return another user's spaces, because the query itself is scoped. This is a simpler invariant to reason about and audit.
Decision 3: Single Lambda for all CRUD
The Lambda handler routes on httpMethod internally — GET, POST, PUT, and DELETE are all handled in one function.
The conventional microservices pattern would give each operation its own Lambda. That's the right call at scale: independent deployment, independent scaling, independent cold start profiles. But for a solo project with modest traffic, one handler means one deployment, one log stream to tail, and one cold start to reason about.
This is a deliberate tradeoff, not an oversight. The codebase is structured to make the migration straightforward: each route's logic lives in its own module, so splitting into separate Lambdas later is a refactor, not a rewrite.
Decision 4: Monitoring wired into CDK
Observability is not an afterthought here. A CloudWatch alarm monitors the API error rate. When it fires, it publishes to an SNS topic, which triggers a dedicated Lambda that POSTs to a Slack webhook.
The entire chain is defined in CDK. There are no manual console configurations to drift from the code. A new deployment recreates the monitoring stack from scratch if needed.
SES is used for transactional email during signup. By default, SES runs in sandbox mode — it only sends to verified addresses. Requesting production access via the AWS console lifts this restriction, but the sandbox is sufficient for development and demo purposes.
Deployment
The full infrastructure deploys with three commands:
npm install
cdk bootstrap # first time only
cdk deploy --all
CDK outputs the API URL, Cognito pool IDs, and CloudFront domain after each deployment. These populate the .env files for both the backend test scripts and the Vite frontend build.
The UIDeploymentStack handles the frontend: it builds the React app, uploads the assets to S3, and automatically invalidates the CloudFront cache. No manual cache invalidation step.
What I'd change at larger scale
Separate Lambdas per route. The single-Lambda approach is pragmatic but doesn't scale operationally. As traffic grows, independent scaling and deployment become important.
DynamoDB single-table design. The current schema partitions spaces per user with a straightforward key structure. A single-table design with composite keys and GSIs would support more access patterns without additional tables.
SES production access from day one. The sandbox restriction catches developers off-guard. Requesting production access early avoids surprises when real users try to sign up.
End-to-end tests against the deployed API. The current test directory contains manual HTTP scripts. Automated integration tests running against the live stack would close the confidence gap between CDK deploy and production readiness.
Conclusion
AWS CDK with TypeScript makes cloud infrastructure feel like application code — typed, composable, and version-controlled. The six-stack structure keeps concerns separate without adding operational overhead. The direct S3 upload pattern and DynamoDB-layer isolation are patterns worth carrying into any multi-tenant AWS application.
The full source is on GitHub: github.com/f2015537/space-finder
Live demo: https://d24kqex7dx8ru7.cloudfront.net

