If you have ever wanted to build a backend without managing a single server, AWS gives you everything you need. In this article, I walk through how I built a fully functional serverless CRUD API using AWS Lambda, DynamoDB, and API Gateway, all written in JavaScript using the AWS SDK v3. I also cover how to secure those endpoints and make the whole system scalable using SQS.
Here is what we will build:
- A DynamoDB table as the database
- Lambda functions for each CRUD operation
- An API Gateway to expose those functions as HTTP endpoints
- Endpoint security using API keys and usage plans
- A scalable upload pipeline using SQS (Producer/Consumer pattern)
The AWS Services at a Glance
Before jumping into code, here is a quick mental model of the services involved:
| Service | Role |
|---|---|
| DynamoDB | NoSQL database where data is stored |
| Lambda | Serverless functions that run your business logic |
| API Gateway | HTTP layer that routes requests to Lambda |
| SQS | Queue that decouples and scales heavy workloads |
| Amplify | Hosts the frontend and connects to the backend |
1. Setting Up DynamoDB
Start by creating a DynamoDB table from the AWS Console. For this project, the table is named aws_introduction-table with id as the partition key (String type).
You can also seed data manually from the console using the DynamoDB JSON format:
{
"id": { "S": "cat-001" },
"name": { "S": "Cloud Whisker" },
"image_url": { "S": "https://example.com/cat.jpg" }
}
Key data type formats in DynamoDB JSON:
- String:
{ "S": "value" } - Number:
{ "N": "123" } - Boolean:
{ "BOOL": true } - List:
{ "L": [...] } - Map:
{ "M": {...} }
2. Writing the Lambda Functions (AWS SDK v3)
Important note on imports: The old
require("aws-sdk")is no longer valid in modern Node.js Lambda runtimes. You must now import only the specific client and command you need.
GET All Items
import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
// Initialize the client once, outside the handler (reused across warm invocations)
const client = new DynamoDBClient({});
export const handler = async (event) => {
const params = {
TableName: "aws_introduction-table",
};
try {
// ScanCommand acts as an envelope: packages your request into a format DynamoDB understands
const command = new ScanCommand(params);
// client.send() carries the command to DynamoDB, gets the response, and returns it
const data = await client.send(command);
// unmarshall converts DynamoDB's typed format ({ S: "value" }) into plain JS objects
const cleanItems = data.Items.map((item) => unmarshall(item));
return {
statusCode: 200,
body: JSON.stringify({
message: "Successfully retrieved all items",
items: cleanItems,
}),
};
} catch (err) {
console.error("Error reading from DynamoDB:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
GET a Single Item
For a single item lookup, the function reads an id from the request headers. This requires Lambda Proxy Integration to be enabled on the API Gateway route, otherwise the headers are stripped and the function receives an empty event.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({ region: "us-east-1" });
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
// Support both API Gateway (headers) and direct Lambda invocation (event.id)
const incomingId = event.headers?.["id"] || event.headers?.["Id"];
const finalId = incomingId || event.id;
const params = {
TableName: "aws_introduction-table",
Key: { id: finalId },
};
try {
const command = new GetCommand(params);
const response = await docClient.send(command);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: "Item retrieved successfully",
// GetCommand returns response.Item (singular), not response.Items
data: response.Item,
}),
};
} catch (err) {
console.error("Error reading from DynamoDB:", err);
return {
statusCode: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: err.message }),
};
}
};
POST (Add an Item)
There are two ways to write items to DynamoDB. Pick one approach and stick with it per function.
Option A: Using JavaScript objects (recommended for readability)
Uses DynamoDBDocumentClient from @aws-sdk/lib-dynamodb, which lets you write plain JS objects instead of typed DynamoDB JSON.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
const baseClient = new DynamoDBClient({});
// DynamoDBDocumentClient wraps the base client so you skip the { S: "..." } typing
const docClient = DynamoDBDocumentClient.from(baseClient);
export const handler = async (event) => {
const { userId, userName, email } = event;
const params = {
TableName: "aws_introduction-table",
Item: {
id: userId,
name: userName,
email: email,
createdAt: new Date().toISOString(),
},
};
try {
const command = new PutCommand(params);
await docClient.send(command);
return {
statusCode: 201,
body: JSON.stringify({
message: "Item added successfully!",
savedItem: params.Item,
}),
};
} catch (err) {
console.error("Error adding item:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
Option B: Using raw DynamoDB JSON format
If you prefer (or need) to use the raw typed format, use PutItemCommand from @aws-sdk/client-dynamodb instead. Mixing PutCommand (lib-dynamodb) with raw DynamoDB types will cause a type mismatch error.
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});
export const handler = async (event) => {
const { id, name, image_url } = event;
const params = {
TableName: "aws_introduction-table",
Item: {
id: { S: id },
name: { S: name },
image_url: { S: image_url },
},
};
try {
const command = new PutItemCommand(params);
await client.send(command);
return {
statusCode: 201,
body: JSON.stringify({
message: "Item added successfully",
savedItems: params.Item,
}),
};
} catch (err) {
console.error("Error writing to DynamoDB:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
PATCH (Update an Item)
The UpdateExpression syntax is what makes DynamoDB updates flexible. :var is a placeholder that gets replaced by ExpressionAttributeValues.
Using JavaScript objects format:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
const params = {
TableName: "aws_introduction-table",
Key: {
id: event.id,
},
UpdateExpression: "SET image_url = :var", // :var is a placeholder
ExpressionAttributeValues: {
":var": event.image_url, // replaces the placeholder with the incoming value
},
ReturnValues: "ALL_NEW", // returns the entire updated record
};
try {
const command = new UpdateCommand(params);
const response = await docClient.send(command);
return {
statusCode: 200,
body: JSON.stringify("Record successfully updated"),
data: response,
};
} catch (err) {
console.error("Error updating the record:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
Using DynamoDB JSON format:
When using the raw format with UpdateItemCommand, you also need ExpressionAttributeNames to avoid conflicts with DynamoDB reserved keywords.
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});
export const handler = async (event) => {
const params = {
TableName: "aws_introduction-table",
Key: {
id: { S: event.id }, // DynamoDB JSON format requires type tags on the Key too
},
UpdateExpression: "SET #image_url = :var",
ExpressionAttributeNames: {
"#image_url": "image_url", // avoids conflicts with reserved keywords
},
ExpressionAttributeValues: {
":var": { S: event.image_url }, // typed value for DynamoDB JSON format
},
ReturnValues: "ALL_NEW",
};
try {
const command = new UpdateItemCommand(params);
const response = await client.send(command);
return {
statusCode: 200,
body: JSON.stringify("Record successfully updated"),
data: response,
};
} catch (err) {
console.error("Error updating the record:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: err.message }),
};
}
};
DELETE an Item
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
const params = {
TableName: "aws_introduction-table",
Key: {
id: event.id,
},
};
try {
const command = new DeleteCommand(params);
const response = await docClient.send(command);
return {
statusCode: 200,
body: JSON.stringify({
message: "Successfully deleted item",
data: response,
}),
};
} catch (err) {
console.error("Delete failed:", err);
return {
statusCode: 500,
body: JSON.stringify({
message: "Failed to delete item",
error: err.message,
}),
};
}
};
3. Creating the API Gateway
Once all five Lambda functions are deployed, head to API Gateway and create a REST API. For each function, create a resource and attach the appropriate HTTP method (GET, POST, PATCH, DELETE).
After creating each method, use the built-in Test tab to validate it before moving on.
One critical setting for the GET single item endpoint: Make sure to enable Lambda Proxy Integration. Without it, API Gateway acts as an aggressive filter and drops all headers before forwarding the request to Lambda. Since the single item lookup reads the item id from the request header, disabling proxy integration means the event.headers object will always be empty.
4. Securing the Endpoints
Leaving your API Gateway endpoints open to the internet is a bad idea. Here is how to lock them down using three features that work together.
Stages act as environment snapshots of your deployed API. A /dev stage is isolated from a /prod stage, so you can test changes without breaking production.
Usage Plans attach rate limits and quotas to a stage. You define how many requests per second (throttling) and how many total per day or month (quota) a consumer can make. This protects your Lambda concurrency and prevents your DynamoDB from getting hammered.
API Keys are alphanumeric identifiers tied to a usage plan. Every request must include the key, which lets you track usage per client, revoke access, or offer tiered access in the future.
Note: API keys identify clients but they are not a replacement for authentication. For user-level security, combine them with JWT or AWS Cognito.
Testing secured endpoints in Postman:
- For GET and DELETE: include the API key under the Authorization tab
- For POST and PATCH: add it as a header with key
x-api-keyand your API key as the value
5. Making the API Scalable with SQS
AWS Lambda handles up to 1,000 concurrent executions per region by default. For a sudden surge (like 500,000 image uploads in a short window), that ceiling breaks fast.
The solution is to decouple the request intake from the processing using Amazon SQS with a Producer/Consumer Lambda pattern.
Here is how it works:
- The Producer Lambda receives requests from API Gateway and pushes them as messages to an SQS queue. It responds immediately to the client.
- The SQS queue buffers all the messages.
- The Consumer Lambda is triggered automatically by an Event Source Mapping (AWS polls the queue for you) and processes messages in batches.
- When Consumer Lambda hits its concurrency limit, SQS automatically provisions additional Lambda instances to keep draining the queue.
Producer Lambda
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
// Declare the client globally so it is reused across warm Lambda invocations
const client = new SQSClient({});
const SQS_QUEUE_URL =
"https://sqs.us-east-1.amazonaws.com/YOUR_ACCOUNT_ID/your-queue-name";
export const handler = async (event) => {
const params = {
QueueUrl: SQS_QUEUE_URL,
MessageBody: JSON.stringify({
timestamp: new Date().toISOString(),
data: event,
}),
};
try {
const command = new SendMessageCommand(params);
const response = await client.send(command);
console.log(`Message sent. ID: ${response.MessageId}`);
return {
statusCode: 200,
body: JSON.stringify({
message: "Event queued successfully!",
messageId: response.MessageId,
}),
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
message: "Error sending message to SQS",
error: error.message,
}),
};
}
};
Before testing: Go to IAM, find the Producer Lambda's execution role, and grant it sqs:SendMessage permission on your queue.
Consumer Lambda
The Consumer Lambda must not return an HTTP response like { statusCode: 200 }. It is triggered directly by SQS, not by API Gateway, so AWS ignores the return value. More importantly, if you put a return inside your for loop, it exits after the first message and leaves the rest of the batch unprocessed.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
// By the time this handler runs, AWS has already polled the queue for you.
// The messages are handed to you inside event.Records.
try {
const writeResults = [];
for (const record of event.Records) {
// Step 1: Parse the SQS message body
const messageBody = JSON.parse(record.body);
// Step 2: The producer wrapped the API Gateway event inside messageBody.data
// API Gateway puts the POST body inside data.body as a string, so parse it again
const apiGatewayBodyString = messageBody.data.body;
const eventInfo = JSON.parse(apiGatewayBodyString);
console.log("Processing message:", messageBody);
console.log("Event info:", eventInfo);
// Step 3: Write to DynamoDB
const params = {
TableName: "your-event-image-table",
Item: eventInfo,
};
const command = new PutCommand(params);
const response = await docClient.send(command);
writeResults.push(response);
}
// SQS consumers return a generic status, not an HTTP response
return {
message: "Batch processed successfully",
processedCount: event.Records.length,
};
} catch (error) {
console.error("Batch processing failed:", error);
// Throwing causes SQS to retry the batch. If a message fails 3 times,
// it is sent to a Dead Letter Queue (DLQ) for inspection.
throw error;
}
};
After deploying the Consumer Lambda:
- Add an Event Source Mapping by going to the Lambda trigger settings and selecting your SQS queue. This is what tells AWS to continuously poll the queue and invoke your handler automatically.
- Go to IAM and grant the Consumer Lambda's role:
sqs:ReceiveMessage,sqs:DeleteMessage,sqs:GetQueueAttributes, and full DynamoDB access.
6. Hosting the Frontend on AWS Amplify
Once the backend is solid, the frontend (React + TypeScript + Vite in my case) gets deployed on AWS Amplify, which connects to a GitHub repo and handles builds and deployments automatically.
Here is a quick breakdown of what Amplify uses under the hood:
- S3 stores all the static assets (HTML, CSS, JS, images)
- CloudFront serves those assets via AWS's CDN, keeping latency low no matter where the user is
- WAF (Web Application Firewall) filters out malicious traffic like SQL injection and bot requests before they reach your app
- Route 53 lets you connect a custom domain to the Amplify-generated URL
One gotcha with S3 permissions: When writing IAM policies for Lambda to upload files to S3, always append /* to the bucket ARN in the resource field:
arn:aws:s3:::my-bucket/* ✅ Correct — targets objects inside the bucket
arn:aws:s3:::my-bucket ❌ Wrong — targets the bucket container itself
s3:PutObject is an object-level action. Without /*, Lambda will get an Access Denied error even with the policy attached.
Another gotcha with CORS: When Lambda Proxy Integration is enabled on API Gateway, API Gateway does not add CORS headers for you. Your Lambda function must return them explicitly in every response:
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, x-api-key",
},
body: JSON.stringify({ message: "Success" }),
};
Wrapping Up
This stack (Lambda + DynamoDB + API Gateway + SQS + Amplify) gives you a backend that is fully serverless, scales automatically, and costs almost nothing at low traffic since you only pay per invocation.
The biggest things I learned building this:
- The
@aws-sdk/v3import style is stricter but cleaner. Import only what you use. - The choice between
DynamoDBDocumentClient(JS objects) and rawDynamoDBClient(DynamoDB JSON) affects which commands you use throughout. Don't mix them. - Lambda Proxy Integration on API Gateway is not optional when your function reads headers or query params.
- SQS consumers should never have
returninside the processing loop, and they should alwaysthrowon failure so SQS retries the batch. - CORS headers must live in your Lambda response when proxy integration is on, not in the API Gateway settings.
If you are just getting started with AWS, this is a solid first project. You touch IAM, compute, storage, queuing, and hosting all in one build.
Have questions or want to see the frontend code too? Drop a comment below.


Top comments (0)