+1 (726) 207-9872

Deploying a REST API with Serverless Framework V4, Lambda, and DynamoDB (2026 Edition)

This is the tutorial we wish existed when new engineers join a Serverless Framework project: a complete, current REST API on AWS Lambda and DynamoDB, written for Serverless Framework V4, Node.js 22, and AWS SDK for JavaScript v3, with TypeScript bundled by the framework itself. No plugins required.

You will build a small notes API with create, read, list, and delete endpoints behind API Gateway HTTP API.

Prerequisites

  • Node.js 22 installed locally.
  • An AWS account and credentials configured (aws sts get-caller-identity should succeed). See the credentials guide if not.
  • Serverless Framework V4, authenticated:
npm install -g serverless
serverless login        # or export SERVERLESS_ACCESS_KEY / SERVERLESS_LICENSE_KEY
serverless --version    # 4.x

1. Project setup

mkdir notes-api && cd notes-api
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev typescript @types/aws-lambda @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --strict

You do not need serverless-esbuild or a webpack config. V4 detects .ts handlers and bundles them with esbuild.

2. serverless.yml

service: notes-api
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs22.x
  region: us-east-1
  architecture: arm64
  memorySize: 512
  timeout: 10
  environment:
    TABLE_NAME: ${param:tableName}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:PutItem
            - dynamodb:GetItem
            - dynamodb:DeleteItem
            - dynamodb:Query
          Resource:
            - !GetAtt NotesTable.Arn

stages:
  default:
    params:
      tableName: notes-${sls:stage}

build:
  esbuild:
    bundle: true
    minify: true
    sourcemap: true
    target: node22
    exclude:
      - '@aws-sdk/*'

functions:
  createNote:
    handler: src/handlers/create.handler
    events:
      - httpApi:
          path: /notes
          method: post
  getNote:
    handler: src/handlers/get.handler
    events:
      - httpApi:
          path: /notes/{id}
          method: get
  listNotes:
    handler: src/handlers/list.handler
    events:
      - httpApi:
          path: /notes
          method: get
  deleteNote:
    handler: src/handlers/delete.handler
    events:
      - httpApi:
          path: /notes/{id}
          method: delete

resources:
  Resources:
    NotesTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${param:tableName}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: pk
            AttributeType: S
          - AttributeName: sk
            AttributeType: S
        KeySchema:
          - AttributeName: pk
            KeyType: HASH
          - AttributeName: sk
            KeyType: RANGE

A few deliberate choices:

  • arm64 (Graviton) is cheaper per millisecond and usually faster for Node workloads. There is no reason to default to x86 in 2026.
  • httpApi (API Gateway HTTP API) is cheaper and lower latency than the REST API type. Use http (REST) only when you need request validation, usage plans, or edge-optimized endpoints.
  • exclude: ['@aws-sdk/*'] keeps the bundle small because the Node 22 runtime already ships SDK v3.
  • Single-table keys (pk/sk) so you can add item types later without a migration.

3. A shared DynamoDB client

src/lib/db.ts:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
export const ddb = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
});
export const TABLE_NAME = process.env.TABLE_NAME!;

Creating the client at module scope, outside the handler, means it is reused across warm invocations.

4. Handlers

src/handlers/create.ts:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { randomUUID } from 'node:crypto';
import { ddb, TABLE_NAME } from '../lib/db.js';

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const body = event.body ? JSON.parse(event.body) : {};
  if (typeof body.text !== 'string' || body.text.length === 0) {
    return { statusCode: 400, body: JSON.stringify({ error: 'text is required' }) };
  }
  const note = {
    pk: 'NOTE',
    sk: randomUUID(),
    text: body.text,
    createdAt: new Date().toISOString(),
  };
  await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: note }));
  return {
    statusCode: 201,
    body: JSON.stringify({ id: note.sk, text: note.text, createdAt: note.createdAt }),
  };
};

src/handlers/get.ts:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { GetCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE_NAME } from '../lib/db.js';

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const id = event.pathParameters?.id;
  const result = await ddb.send(
    new GetCommand({ TableName: TABLE_NAME, Key: { pk: 'NOTE', sk: id } }),
  );
  if (!result.Item) return { statusCode: 404, body: JSON.stringify({ error: 'not found' }) };
  const { sk, text, createdAt } = result.Item;
  return { statusCode: 200, body: JSON.stringify({ id: sk, text, createdAt }) };
};

src/handlers/list.ts:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { QueryCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE_NAME } from '../lib/db.js';

export const handler: APIGatewayProxyHandlerV2 = async () => {
  const result = await ddb.send(
    new QueryCommand({
      TableName: TABLE_NAME,
      KeyConditionExpression: 'pk = :pk',
      ExpressionAttributeValues: { ':pk': 'NOTE' },
      Limit: 50,
    }),
  );
  const items = (result.Items ?? []).map(({ sk, text, createdAt }) => ({ id: sk, text, createdAt }));
  return { statusCode: 200, body: JSON.stringify({ items }) };
};

src/handlers/delete.ts:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, TABLE_NAME } from '../lib/db.js';

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  await ddb.send(
    new DeleteCommand({ TableName: TABLE_NAME, Key: { pk: 'NOTE', sk: event.pathParameters?.id } }),
  );
  return { statusCode: 204, body: '' };
};

5. Deploy and test

serverless deploy --stage dev

The output lists the HTTP API endpoint. Exercise it:

API=https://abc123.execute-api.us-east-1.amazonaws.com
curl -s -X POST $API/notes -H 'content-type: application/json' -d '{"text":"first note"}'
curl -s $API/notes
curl -s $API/notes/<id>
curl -s -X DELETE $API/notes/<id> -o /dev/null -w '%{http_code}\n'

Tail logs while you do it:

serverless logs --function createNote --stage dev --tail

6. Iterate with serverless dev

V4's dev mode routes invocations of your deployed functions to your local code, so you can edit a handler and re-run a request without redeploying:

serverless dev --stage dev

Use it for development stages only; it is not a production tool.

7. Clean up

serverless remove --stage dev

Remember that under V4 each deployed stage is a licensed Service Instance, so remove stages you are not using.

Where to go next

If you would rather have a senior engineer build the production version with your team, our Serverless API Creation service is exactly that. Contact us.