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-identityshould 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. Usehttp(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
- Add authentication with a JWT authorizer on the HTTP API (
httpApi.authorizersinprovider). - Add structured logging and tracing; see Observability for Serverless in 2026.
- Put this in a pipeline with no stored AWS keys: CI/CD with GitHub Actions and OIDC.
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.