Skip to main content

🛠️ 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.yml manifest — 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.
graph TD A[serverless.yml] --> B[Serverless Framework] B --> C[CloudFormation template] C --> D[API Gateway] C --> E[Lambda functions] C --> F[DynamoDB table] C --> G[IAM roles]

📖 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
SectionPurpose
serviceThe stack's name
providerCloud, runtime, region, stage, global env & permissions
functionsEach Lambda and the events that trigger it
resourcesExtra infrastructure (tables, queues) as raw CloudFormation
pluginsFramework extensions (offline, alarms, custom domains)
customYour own reusable variables

The variable system

Variables make one manifest serve many environments. The syntax is ${source:key, default}:

VariableResolves 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
graph LR A[serverless deploy] --> B[Package service] B --> C[Upload artifacts to S3] C --> D[Create / update CloudFormation stack] D --> E[Provision API, Lambdas, table, roles] E --> F[Print stack outputs]

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 package in 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

  1. Add a toggle function to serverless.yml mapped to PATCH /todos/{id}/toggle.
  2. Write src/functions/toggle.js: load the todo, flip completed, bump updatedAt, save, and return it (404 if missing).
  3. Test it locally with serverless offline and a curl call.
💡 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.yml manifest 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-offline gives 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.