Build a serverless API on AWS with CDK: Lambda, DynamoDB, CloudFront
A full-stack todo app on AWS with CDK: Lambda, an HTTP API, DynamoDB and a React front end on S3 and CloudFront, plus the two bugs the first version shipped.
By Shaheer Q. · · 6 min read
A serverless API on AWS needs four pieces: Lambda functions for the code, an API Gateway HTTP API to route requests to them, DynamoDB to store data, and, for a web front end, S3 and CloudFront to serve it. With the AWS CDK you define all of it in TypeScript and deploy it with one command, and with no traffic it costs close to nothing.
This tutorial walks through a todo app I built to practise exactly that: a CRUD API on Lambda and DynamoDB, and a React front end on S3 and CloudFront, in two CDK stacks. The code is in my aws-serverless-demo repository. At the end are the two bugs the first version shipped with, because they are the mistakes almost everyone makes on day one.
What you are building
Browser (localhost:5173 or CloudFront)
|
v
API Gateway HTTP API ---> Lambda (Node.js 22) ---> DynamoDB
|
v
S3 + CloudFront (built front end)
| Stack | Resources |
|---|---|
BackendStack | A DynamoDB table, four Lambda functions, an HTTP API with /todos routes |
FrontendStack | A private S3 bucket, a CloudFront distribution, and a deployment that uploads the built front end |
The API is four routes:
| Method | Path | What it does |
|---|---|---|
GET | /todos | List todos |
POST | /todos | Create a todo from { "title": "..." } |
PUT | /todos/{id} | Update title, description and/or completed |
DELETE | /todos/{id} | Delete a todo |
You need Node.js 22, the AWS CLI with credentials configured, and the CDK CLI (npm install -g aws-cdk). Bootstrap your account and region once with cdk bootstrap, which creates the bucket and roles the CDK deploys through.
The table
One table, keyed on the todo's id:
const todosTable = new Table(this, 'TodosTable', {
partitionKey: { name: 'id', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
PAY_PER_REQUEST(on-demand) bills per read and write, with nothing to provision. For a new app with unknown traffic, it is the right default, and an idle table costs only its storage.RemovalPolicy.DESTROYdeletes the table when you delete the stack. That is what you want for a demo you will tear down. In production, leave the default (RETAIN), or onecdk destroydeletes your data.
The functions
Each route gets its own Lambda function. NodejsFunction bundles the TypeScript handler with esbuild during cdk deploy, so there is no separate build step:
const createTodoLambda = new NodejsFunction(this, 'CreateTodoLambda', {
runtime: Runtime.NODEJS_22_X,
entry: 'src/todos/create.ts',
handler: 'handler',
environment: {
TODOS_TABLE_NAME: todosTable.tableName,
},
});
todosTable.grantReadWriteData(createTodoLambda);
Two lines do work you would otherwise do by hand. The table name reaches the function as an environment variable, so nothing is hard-coded. And grantReadWriteData writes the IAM policy that lets this function, and only this function, use this table.
The handler itself is short. The DynamoDB client is created outside the handler, so warm invocations reuse it instead of reconnecting:
const client = new DynamoDBClient({});
const db = DynamoDBDocumentClient.from(client);
const tableName = process.env.TODOS_TABLE_NAME!;
export async function handler(event: any) {
const body = JSON.parse(event.body || '{}');
if (!body.title) {
return { statusCode: 400, body: JSON.stringify({ message: 'title is required' }) };
}
const now = new Date().toISOString();
const todo = {
id: randomUUID(),
title: body.title,
description: body.description || '',
completed: false,
createdAt: now,
updatedAt: now,
};
await db.send(new PutCommand({ TableName: tableName, Item: todo }));
return { statusCode: 201, body: JSON.stringify(todo) };
}
The DynamoDBDocumentClient lets you pass plain JavaScript objects rather than DynamoDB's typed attribute format ({ S: "..." }), which keeps handlers readable.
Updates build the UpdateExpression from whichever fields were sent, so a request that only toggles completed leaves the title alone:
if (body.completed !== undefined) {
updates.push('#completed = :completed');
expressionAttributeNames['#completed'] = 'completed';
expressionAttributeValues[':completed'] = body.completed;
}
// ...same for title and description, then always set updatedAt
await db.send(new UpdateCommand({
TableName: tableName,
Key: { id },
UpdateExpression: `SET ${updates.join(', ')}`,
ExpressionAttributeNames: expressionAttributeNames,
ExpressionAttributeValues: expressionAttributeValues,
ReturnValues: 'ALL_NEW',
}));
The #name placeholders are not decoration: common attribute names such as name, status and data are DynamoDB reserved words, and an expression that uses them directly fails. Using placeholders for every field means you never have to remember which.
The API
An HTTP API is API Gateway's cheaper, simpler flavour, and it is enough for a Lambda-backed REST API. Each route maps a method and path to a function:
const api = new HttpApi(this, 'TodoApi', {
apiName: 'todo-api',
corsPreflight: {
allowOrigins: ['http://localhost:5173', 'https://<your-distribution>.cloudfront.net'],
allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST, CorsHttpMethod.PUT, CorsHttpMethod.DELETE],
allowHeaders: ['Content-Type'],
},
});
api.addRoutes({
path: '/todos/{id}',
methods: [HttpMethod.PUT],
integration: new HttpLambdaIntegration('UpdateTodoIntegration', updateTodoLambda),
});
new cdk.CfnOutput(this, 'ApiUrl', { value: api.url! });
{id} arrives in the handler as event.pathParameters.id. The CfnOutput prints the API's URL after deploy, which the front end needs.
CORS is needed because the front end and the API live on different domains. The allowed origins are the Vite dev server and the CloudFront domain. That second entry is a chicken-and-egg problem: you only know the CloudFront domain after the front-end stack exists. I deployed once, copied the domain in and redeployed. The fixes are below.
The front end on S3 and CloudFront
The React app (Vite and TypeScript) builds to static files. The second stack puts them in a private bucket behind CloudFront:
const bucket = new s3.Bucket(this, 'FrontendBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
const distribution = new cloudfront.Distribution(this, 'FrontendDistribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
defaultRootObject: 'index.html',
});
new s3deploy.BucketDeployment(this, 'DeployFrontend', {
sources: [s3deploy.Source.asset('../frontend/dist')],
destinationBucket: bucket,
distribution,
distributionPaths: ['/*'],
});
- The bucket stays private.
S3BucketOrigin.withOriginAccessControlsets up Origin Access Control and the bucket policy that lets only this distribution read it. No public bucket, no static-website hosting. BucketDeploymentuploadsfrontend/diston every deploy and invalidates the CloudFront cache (/*), so visitors get the new build straight away.autoDeleteObjectsempties the bucket oncdk destroy; CloudFormation cannot delete a bucket that still has files in it.
Deploying
Order matters, because the front end needs the API's URL at build time:
cd backend && npm install
npx cdk deploy BackendStack # prints ApiUrl
# put ApiUrl into frontend/.env as VITE_API_URL (no trailing slash)
cd ../frontend && npm install && npm run build
cd ../backend
npx cdk deploy FrontendStack # prints FrontendUrl
npx cdk diff before a deploy shows exactly what will change, and npx cdk destroy BackendStack FrontendStack removes everything.
The two bugs the first version shipped
The code above works for the happy path. Two things break as soon as someone sends something unexpected.
1. Updating a todo that does not exist creates one
UpdateItem in DynamoDB is an upsert. If no item has that key, it creates one containing only the attributes in the update. So PUT /todos/does-not-exist with { "completed": true } returns 200 and leaves a todo with no title in the table, and the next GET /todos shows a blank row.
The fix is a condition, and a 404 when it fails:
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
try {
const result = await db.send(new UpdateCommand({
// ...as before
ConditionExpression: 'attribute_exists(id)',
}));
return { statusCode: 200, body: JSON.stringify(result.Attributes) };
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
return { statusCode: 404, body: JSON.stringify({ message: 'Todo not found' }) };
}
throw error;
}
DeleteItem has the mirror image: deleting a missing key succeeds silently. Use the same condition there if the client needs to know.
2. A malformed body crashes the create handler
The update and delete handlers wrap their work in try/catch. The create handler does not, and its first line is JSON.parse(event.body). Send it invalid JSON and the function throws. API Gateway turns the unhandled error into a 500 "Internal Server Error", when the client actually sent a bad request.
Parse inside a try and return a 400. Better still, validate the body with a schema library such as Zod, so a title that is a number or 10,000 characters long is rejected too:
import { z } from 'zod';
const CreateTodo = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().max(2000).optional(),
});
let input;
try {
input = CreateTodo.parse(JSON.parse(event.body ?? '{}'));
} catch {
return { statusCode: 400, body: JSON.stringify({ message: 'Invalid todo' }) };
}
Before you build on it
This is a learning project, and a few choices would not survive real users:
Scanlists every item in the table. It is fine for a handful of todos, but it reads the whole table on every request and pages at 1 MB. With users, key todos by user (pk = USER#<id>) andQueryone user's items instead.- There is no authentication. Anyone with the URL can read and delete every todo. A Cognito user pool or a JWT authorizer on the HTTP API is the usual next step.
- Grant the least access. The list function only reads, so
grantReadDatais enough for it.grantReadWriteDataeverywhere is convenient, not necessary. - Remove the CORS chicken-and-egg. Either pass the distribution's domain from
FrontendStackintoBackendStackas a prop (deploying the front-end stack first), or route/api/*through the same CloudFront distribution as a second origin, so the browser sees one domain and CORS goes away. - Type the event. Replace
event: anywithAPIGatewayProxyEventV2from@types/aws-lambdato catch typos inpathParametersat compile time.
The CDK keeps every AWS resource visible, which is why I like it for learning. For a larger app I now reach for SST, which wraps the same pieces in higher-level components; this site is built that way, and I wrote up how it runs on AWS with SST. If you want the queue-based version of a serverless backend, the SQS producer and consumer tutorial is the next step.
Frequently asked questions
What does an idle serverless API on AWS cost?
Close to nothing. Lambda, API Gateway HTTP APIs and DynamoDB on-demand all bill per request, so with no traffic you pay only for storage: a few cents for the table, the S3 bucket and CloudFront. There is no server running between requests.
Why does DynamoDB UpdateItem create an item that does not exist?
UpdateItem is an upsert: if no item has that key, it creates one with only the attributes in your update. Add a condition expression such as attribute_exists(id) to make it fail instead, then return a 404.
Should I use the AWS CDK or SST?
Both define infrastructure in TypeScript. The CDK is AWS's own framework and compiles to CloudFormation, with every AWS service available. SST adds higher-level components and a live development mode on top. For learning how the AWS pieces fit together, the CDK keeps them visible.
Do I need CORS if the front end and API are both on AWS?
Yes, if they are on different domains, such as a CloudFront URL and an API Gateway URL. You can avoid CORS entirely by routing the API through the same CloudFront distribution under a path like /api.