🛠️ Building APIs with the Serverless Framework
Deploying Lambda functions one at a time with raw CLI commands gets old fast. The Serverless Framework lets you declare an entire API — functions, routes, database, and permissions — in a single manifest and ship it with one command. In this lesson you'll build a complete CRUD todo API, test it locally, and deploy it through CI/CD.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what the Serverless Framework is and how it relates to CloudFormation
- Read and write a
serverless.ymlmanifest — provider, functions, events, resources - Use the framework's variable system for stage- and environment-aware config
- Implement a full CRUD REST API on Lambda + DynamoDB with shared helper libraries
- Run and test the API locally with
serverless-offline - Deploy the stack and automate it in a CI/CD pipeline
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build, run offline, and extend a five-endpoint todo API.
In This Lesson
What the Framework Does
The Serverless Framework is an open-source toolkit that turns a concise YAML manifest into deployed cloud infrastructure. On AWS it compiles your serverless.yml into a CloudFormation template, uploads your code, and provisions everything as a single, versioned, rollback-able stack.
💡 Analogy — a universal remote. Before universal remotes, every device needed its own controller. The Serverless Framework is a universal remote for the cloud: one consistent interface where "deploy" replaces dozens of provider-specific steps, and swapping the target (a stage, a region, even a provider) doesn't change how you press the buttons.
📖 Framework vs. SAM
AWS SAM (last lesson) and the Serverless Framework both compile to CloudFormation and both are excellent. SAM is AWS-native and CloudFormation-flavoured; the Serverless Framework is provider-agnostic, has a huge plugin ecosystem, and a rich variable system. Learning one makes the other easy.
Setup & a New Project
Install the CLI and scaffold a project:
# Install the Serverless Framework CLI
npm install -g serverless
# Confirm it's installed
serverless --version
# Scaffold a new AWS + Node.js service
serverless create --template aws-nodejs --path todo-api
cd todo-api
AWS credentials
The framework needs AWS credentials to create resources. Prefer a dedicated IAM user (never your root account) or, better, short-lived role credentials in CI:
# Configure credentials via the AWS CLI (recommended)
aws configure
# Deploy using a named profile
serverless deploy --aws-profile development
⚠️ Never enter credentials into this course or commit them
Keep access keys out of source control. Use named profiles locally and CI secrets (or OIDC role assumption) in pipelines. Apply least privilege to the deploy identity, and rotate keys periodically.
Anatomy of serverless.yml
The manifest is the heart of the project. A minimal version:
service: todo-api
provider:
name: aws
runtime: nodejs20.x
stage: ${opt:stage, 'dev'}
region: ${opt:region, 'us-east-1'}
environment:
STAGE: ${self:provider.stage}
functions:
hello:
handler: handler.hello
events:
- httpApi:
path: /hello
method: get
plugins:
- serverless-offline
| Section | Purpose |
|---|---|
service | The stack's name |
provider | Cloud, runtime, region, stage, global env & permissions |
functions | Each Lambda and the events that trigger it |
resources | Extra infrastructure (tables, queues) as raw CloudFormation |
plugins | Framework extensions (offline, alarms, custom domains) |
custom | Your own reusable variables |
The variable system
Variables make one manifest serve many environments. The syntax is ${source:key, default}:
| Variable | Resolves to |
|---|---|
${self:service} | Another property in this file |
${opt:stage, 'dev'} | A CLI flag, with a fallback |
${env:VAR_NAME} | An environment variable |
${ssm:/path/to/param} | An SSM Parameter Store value |
${cf:otherStack.Output} | A CloudFormation stack output |
This lets you build resource names like ${self:service}-${self:provider.stage}-todos so dev and prod never collide.
Declaring the Todo API
Here's the full manifest for a five-endpoint CRUD service backed by DynamoDB. Note how the IAM permissions are scoped to only this table, and how the table itself is declared as a CloudFormation resource:
service: todo-api
provider:
name: aws
runtime: nodejs20.x
stage: ${opt:stage, 'dev'}
region: ${opt:region, 'us-east-1'}
environment:
TODOS_TABLE: ${self:service}-${self:provider.stage}-todos
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:DeleteItem
Resource: !GetAtt TodosTable.Arn
functions:
create:
handler: src/functions/create.handler
events:
- httpApi: { path: /todos, method: post }
list:
handler: src/functions/list.handler
events:
- httpApi: { path: /todos, method: get }
get:
handler: src/functions/get.handler
events:
- httpApi: { path: /todos/{id}, method: get }
update:
handler: src/functions/update.handler
events:
- httpApi: { path: /todos/{id}, method: put }
remove:
handler: src/functions/remove.handler
events:
- httpApi: { path: /todos/{id}, method: delete }
resources:
Resources:
TodosTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.TODOS_TABLE}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
plugins:
- serverless-offline
💡 Why httpApi?
The httpApi event uses API Gateway's HTTP API — cheaper, faster, and with built-in CORS — versus the older, heavier REST API (http). Use httpApi for new projects unless you need a REST-API-only feature.
Recommended project structure
todo-api/
├── serverless.yml
├── package.json
└── src/
├── functions/
│ ├── create.js
│ ├── list.js
│ ├── get.js
│ ├── update.js
│ └── remove.js
└── libs/
├── dynamodb.js # shared client
├── response.js # response helpers
└── validator.js # input validation
Implementing CRUD
First, the shared libraries. Keeping the client, response shaping, and validation in one place keeps each handler tiny and consistent.
Shared client & helpers
// src/libs/dynamodb.js (AWS SDK v3)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
const config = process.env.IS_OFFLINE
? { region: 'localhost', endpoint: 'http://localhost:8000' }
: {};
export const ddb = DynamoDBDocumentClient.from(new DynamoDBClient(config));
export const TABLE = process.env.TODOS_TABLE;
// src/libs/response.js
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
};
export const success = (body, statusCode = 200) => ({
statusCode,
headers,
body: JSON.stringify(body),
});
export const failure = (statusCode, message, details) => ({
statusCode,
headers,
body: JSON.stringify(details ? { error: message, details } : { error: message }),
});
// src/libs/validator.js
export function validateTodo(todo) {
const errors = [];
if (!todo || typeof todo.title !== 'string' || todo.title.trim() === '') {
errors.push('title is required');
}
if (todo?.title && todo.title.length > 100) {
errors.push('title must be 100 characters or fewer');
}
return { valid: errors.length === 0, errors };
}
Create
// src/functions/create.js
import { randomUUID } from 'node:crypto';
import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
import { validateTodo } from '../libs/validator.js';
export const handler = async (event) => {
try {
const body = JSON.parse(event.body ?? '{}');
const { valid, errors } = validateTodo(body);
if (!valid) return failure(400, 'Invalid todo data', errors);
const now = new Date().toISOString();
const todo = {
id: randomUUID(),
title: body.title,
description: body.description ?? '',
completed: body.completed ?? false,
createdAt: now,
updatedAt: now,
};
await ddb.send(new PutCommand({ TableName: TABLE, Item: todo }));
return success(todo, 201);
} catch (err) {
console.error('create failed:', err);
return failure(500, 'Could not create todo');
}
};
List & Get
// src/functions/list.js
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
export const handler = async () => {
try {
const { Items = [] } = await ddb.send(new ScanCommand({ TableName: TABLE }));
Items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return success({ count: Items.length, todos: Items });
} catch (err) {
console.error('list failed:', err);
return failure(500, 'Could not list todos');
}
};
// src/functions/get.js
import { GetCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
export const handler = async (event) => {
try {
const { id } = event.pathParameters;
const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { id } }));
return Item ? success(Item) : failure(404, 'Todo not found');
} catch (err) {
console.error('get failed:', err);
return failure(500, 'Could not get todo');
}
};
Update & Delete
// src/functions/update.js
import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
import { validateTodo } from '../libs/validator.js';
export const handler = async (event) => {
try {
const { id } = event.pathParameters;
const body = JSON.parse(event.body ?? '{}');
const { valid, errors } = validateTodo(body);
if (!valid) return failure(400, 'Invalid todo data', errors);
const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!Item) return failure(404, 'Todo not found');
const updated = {
...Item,
title: body.title,
description: body.description ?? Item.description,
completed: body.completed ?? Item.completed,
updatedAt: new Date().toISOString(),
};
await ddb.send(new PutCommand({ TableName: TABLE, Item: updated }));
return success(updated);
} catch (err) {
console.error('update failed:', err);
return failure(500, 'Could not update todo');
}
};
// src/functions/remove.js
import { DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
export const handler = async (event) => {
try {
const { id } = event.pathParameters;
await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { id } }));
return success({ message: 'Todo deleted', id });
} catch (err) {
console.error('remove failed:', err);
return failure(500, 'Could not delete todo');
}
};
Local Development & Testing
The serverless-offline plugin emulates API Gateway and Lambda on your machine, so you can iterate without deploying:
npm install --save-dev serverless-offline
serverless offline
# → Server ready: http://localhost:3000
Try it with curl
# Create
curl -X POST http://localhost:3000/todos \
-H 'Content-Type: application/json' \
-d '{"title":"Learn the Serverless Framework"}'
# List
curl http://localhost:3000/todos
# Update
curl -X PUT http://localhost:3000/todos/<id> \
-H 'Content-Type: application/json' \
-d '{"title":"Done!","completed":true}'
# Delete
curl -X DELETE http://localhost:3000/todos/<id>
A unit test for the create handler
Test the handler in isolation by mocking the DynamoDB client. With the AWS SDK v3, aws-sdk-client-mock makes this clean:
// tests/create.test.js
import { mockClient } from 'aws-sdk-client-mock';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { handler } from '../src/functions/create.js';
const ddbMock = mockClient(DynamoDBDocumentClient);
beforeEach(() => {
ddbMock.reset();
process.env.TODOS_TABLE = 'test-todos';
});
test('creates a todo and returns 201', async () => {
ddbMock.on(PutCommand).resolves({});
const res = await handler({ body: JSON.stringify({ title: 'Test' }) });
expect(res.statusCode).toBe(201);
expect(JSON.parse(res.body).title).toBe('Test');
});
test('rejects a missing title with 400', async () => {
const res = await handler({ body: JSON.stringify({ description: 'no title' }) });
expect(res.statusCode).toBe(400);
});
Deployment & CI/CD
One command packages the code, uploads artifacts, and creates or updates the CloudFormation stack:
# Deploy the default (dev) stage
serverless deploy
# Deploy a specific stage
serverless deploy --stage production
# Ship one function fast (skips the full CloudFormation update)
serverless deploy function --function create
Automating with GitHub Actions
A pipeline that tests, then deploys dev from develop and prod from main:
# .github/workflows/deploy.yml
name: Deploy Serverless API
on:
push:
branches: [main, develop]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
- name: Pick stage
run: echo "STAGE=${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}" >> "$GITHUB_ENV"
- name: Deploy
run: npx serverless deploy --stage "$STAGE"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
✅ CI/CD best practices
- Separate stages for dev, staging, and prod — never test in production.
- Run tests before deploy so a red build never ships.
- Prefer OIDC role assumption over long-lived AWS keys in secrets where possible.
- Validate the template with
serverless packagein review.
💡 Beyond the basics
The plugin ecosystem covers the rest of production life: serverless-domain-manager for custom domains, Cognito authorizers for auth, provisionedConcurrency to tame cold starts on hot paths, and serverless-plugin-aws-alerts for CloudWatch alarms on errors and duration.
Hands-on Exercise
🏋️ Add a "toggle complete" endpoint
Objective: Extend the todo API with a focused endpoint that flips a todo's completed flag — practising the full loop of manifest + handler + local test.
Your task
- Add a
togglefunction toserverless.ymlmapped toPATCH /todos/{id}/toggle. - Write
src/functions/toggle.js: load the todo, flipcompleted, bumpupdatedAt, save, and return it (404 if missing). - Test it locally with
serverless offlineand acurlcall.
💡 Hint
You already have GetCommand and PutCommand patterns in update.js — reuse the shared ddb, success, and failure helpers. No new validation is needed since the client sends no body.
✅ Example solution
Manifest addition:
toggle:
handler: src/functions/toggle.handler
events:
- httpApi: { path: /todos/{id}/toggle, method: patch }
Handler:
// src/functions/toggle.js
import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE } from '../libs/dynamodb.js';
import { success, failure } from '../libs/response.js';
export const handler = async (event) => {
try {
const { id } = event.pathParameters;
const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!Item) return failure(404, 'Todo not found');
const updated = {
...Item,
completed: !Item.completed,
updatedAt: new Date().toISOString(),
};
await ddb.send(new PutCommand({ TableName: TABLE, Item: updated }));
return success(updated);
} catch (err) {
console.error('toggle failed:', err);
return failure(500, 'Could not toggle todo');
}
};
Test: curl -X PATCH http://localhost:3000/todos/<id>/toggle — the returned todo should show completed flipped.
Quiz
🎯 Check Your Understanding
Question 1: When you run serverless deploy on AWS, what does the framework produce under the hood?
Question 2: What does ${opt:stage, 'dev'} mean in serverless.yml?
Question 3: Why is the serverless-offline plugin useful?
Summary & Next Steps
🎉 Key Takeaways
- The Serverless Framework turns a
serverless.ymlmanifest into a CloudFormation stack you deploy with one command. - The variable system (
${opt:…},${self:…},${ssm:…}) makes a single manifest serve every stage and region. - A clean project keeps handlers thin and pushes shared logic into libs (client, response, validation).
serverless-offlinegives you a fast local loop; mock the SDK v3 client for unit tests.- CI/CD with per-branch stages and pre-deploy tests turns deploys into a safe, boring routine.
📚 Further Reading
🚀 What's Next?
You've now built serverless APIs from principles to a deployed, tested stack. Next you'll put it all together in the Weekend Project: Advanced Backend & APIs, combining these skills into one substantial build.