Here’s the complete solution for every step, including the DynamoDB table creation, IAM policy, handler code, deployment commands, and expected output at each stage.
Why This Works
- The table schema aligns with the access pattern in the handler:
userIdgroups one user’s items together anditemIduniquely identifies each record within that partition. - The Lambda role is scoped to one table, so the API gets the exact data access it needs without turning into
dynamodb:*on*. - The POST, GET, and DELETE tests prove the whole request lifecycle, not just whether DynamoDB accepted a table definition.
If you want AWS’s version of the table and query behavior open while you work, keep the aws dynamodb create-table command reference, the DynamoDB Query guide, and the DynamoDB Scan guide nearby.
Create the DynamoDB Table
aws dynamodb create-table \
--table-name my-frontend-app-data \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=itemId,AttributeType=S \
--key-schema \
AttributeName=userId,KeyType=HASH \
AttributeName=itemId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region us-east-1 \
--output jsonExpected output:
{
"TableDescription": {
"TableName": "my-frontend-app-data",
"TableStatus": "CREATING",
"KeySchema": [
{
"AttributeName": "userId",
"KeyType": "HASH"
},
{
"AttributeName": "itemId",
"KeyType": "RANGE"
}
],
"BillingModeSummary": {
"BillingMode": "PAY_PER_REQUEST"
},
"TableArn": "arn:aws:dynamodb:us-east-1:123456789012:table/my-frontend-app-data"
}
}Wait for the table to become active:
aws dynamodb wait table-exists \
--table-name my-frontend-app-data \
--region us-east-1Verify:
aws dynamodb describe-table \
--table-name my-frontend-app-data \
--region us-east-1 \
--output json \
--query "Table.TableStatus"Expected output: "ACTIVE"
Add DynamoDB Permissions to the Lambda Role
lambda-dynamodb-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowDynamoDBAccess",
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/my-frontend-app-data"
}
]
}Create and attach the policy
aws iam create-policy \
--policy-name MyFrontendAppLambdaDynamoDB \
--policy-document file://lambda-dynamodb-policy.json \
--region us-east-1 \
--output jsonaws iam attach-role-policy \
--role-name my-frontend-app-lambda-role \
--policy-arn arn:aws:iam::123456789012:policy/MyFrontendAppLambdaDynamoDB \
--region us-east-1 \
--output jsonVerify
aws iam list-attached-role-policies \
--role-name my-frontend-app-lambda-role \
--region us-east-1 \
--output jsonExpected output:
{
"AttachedPolicies": [
{
"PolicyName": "AWSLambdaBasicExecutionRole",
"PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
},
{
"PolicyName": "MyFrontendAppLambdaDynamoDB",
"PolicyArn": "arn:aws:iam::123456789012:policy/MyFrontendAppLambdaDynamoDB"
}
]
}Install SDK and Update the Handler
cd lambda
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodblambda/src/handler.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
DeleteCommand,
QueryCommand,
} from '@aws-sdk/lib-dynamodb';
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
Note The client is created outside the handler so it persists across warm invocations.const TABLE_NAME = process.env.TABLE_NAME ?? 'my-frontend-app-data';
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const method = event.requestContext.http.method;
const userId = event.queryStringParameters?.userId;
if (!userId) {
return respond(400, { error: 'Missing userId parameter' });
}
try {
switch (method) {
case 'GET': {
const itemId = event.queryStringParameters?.itemId;
if (itemId) {
const result = await client.send(
new GetCommand({
TableName: TABLE_NAME,
Key: { userId, itemId },
}),
);
if (!result.Item) {
return respond(404, { error: 'Item not found' });
}
return respond(200, result.Item);
}
const result = await client.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'userId = :userId',
ExpressionAttributeValues: { ':userId': userId },
}),
);
return respond(200, { items: result.Items ?? [] });
}
case 'POST': {
const body = JSON.parse(event.body ?? '{}');
if (!body.title) {
return respond(400, { error: 'Missing title in request body' });
}
const itemId = `item-${Date.now()}`;
const item = {
userId,
itemId,
title: body.title,
status: body.status ?? 'pending',
createdAt: new Date().toISOString(),
};
await client.send(
new PutCommand({
TableName: TABLE_NAME,
Item: item,
}),
);
return respond(201, item);
}
case 'DELETE': {
const itemId = event.queryStringParameters?.itemId;
if (!itemId) {
return respond(400, { error: 'Missing itemId parameter' });
}
await client.send(
new DeleteCommand({
TableName: TABLE_NAME,
Key: { userId, itemId },
}),
);
return respond(200, { deleted: true });
}
default:
return respond(405, { error: 'Method not allowed' });
}
} catch (error) {
console.error('Handler error:', error);
return respond(500, { error: 'Internal server error' });
}
};
function respond(statusCode: number, body: Record<string, unknown>) {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify(body),
};
}Build
cd lambda
npm run buildExpected: dist/handler.js is created with no TypeScript errors.
Set Environment Variable and Deploy
Set the environment variable
aws lambda update-function-configuration \
--function-name my-frontend-app-api \
--environment 'Variables={TABLE_NAME=my-frontend-app-data}' \
--region us-east-1 \
--output jsonPackage and deploy
cd lambda/dist
zip -r ../function.zip .
cd ..
aws lambda update-function-code \
--function-name my-frontend-app-api \
--zip-file fileb://function.zip \
--region us-east-1 \
--output jsonVerify the environment variable
aws lambda get-function-configuration \
--function-name my-frontend-app-api \
--region us-east-1 \
--output json \
--query "Environment"Expected output:
{
"Variables": {
"TABLE_NAME": "my-frontend-app-data"
}
}Test Creating an Item
Save as test-create.json:
{
"requestContext": {
"http": {
"method": "POST",
"path": "/"
}
},
"queryStringParameters": {
"userId": "user-123"
},
"body": "{\"title\": \"Learn DynamoDB\"}"
}Invoke:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-create.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected response (formatted):
{
"statusCode": 201,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": "{\"userId\":\"user-123\",\"itemId\":\"item-1710756000000\",\"title\":\"Learn DynamoDB\",\"status\":\"pending\",\"createdAt\":\"2026-03-18T12:00:00.000Z\"}"
}The itemId and createdAt values will differ based on when you run the command.
In the console, the same invocation using the Test tab shows the execution result with the 201 status code and the item’s data.

Create a second item
Save as test-create-2.json:
{
"requestContext": {
"http": {
"method": "POST",
"path": "/"
}
},
"queryStringParameters": {
"userId": "user-123"
},
"body": "{\"title\": \"Deploy to production\"}"
}aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-create-2.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonTest Listing Items
Save as test-list.json:
{
"requestContext": {
"http": {
"method": "GET",
"path": "/"
}
},
"queryStringParameters": {
"userId": "user-123"
}
}aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-list.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected: statusCode: 200 with a body containing an items array with both items.
When you parse the body:
{
"items": [
{
"userId": "user-123",
"itemId": "item-1710756000000",
"title": "Learn DynamoDB",
"status": "pending",
"createdAt": "2026-03-18T12:00:00.000Z"
},
{
"userId": "user-123",
"itemId": "item-1710756001000",
"title": "Deploy to production",
"status": "pending",
"createdAt": "2026-03-18T12:00:01.000Z"
}
]
}Test Deleting an Item
Use the itemId from the first created item. Save as test-delete.json:
{
"requestContext": {
"http": {
"method": "DELETE",
"path": "/"
}
},
"queryStringParameters": {
"userId": "user-123",
"itemId": "item-1710756000000"
}
}Replace item-1710756000000 with the actual itemId from your POST response.
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-delete.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected: statusCode: 200 with body {"deleted":true}.
Verify the item is gone
Re-run the list test:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-list.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected: the items array now contains only one item—the “Deploy to production” item.
Test Error Cases
Missing userId
Save as test-no-user.json:
{
"requestContext": {
"http": {
"method": "GET",
"path": "/"
}
},
"queryStringParameters": {}
}aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-no-user.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected: statusCode: 400 with body {"error":"Missing userId parameter"}.
Unsupported method
Save as test-put.json:
{
"requestContext": {
"http": {
"method": "PUT",
"path": "/"
}
},
"queryStringParameters": {
"userId": "user-123"
}
}aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-put.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected: statusCode: 405 with body {"error":"Method not allowed"}.
Stretch Goal: Update Endpoint
To handle PATCH requests for updating the status, add this case to the switch statement:
case 'PATCH': {
const itemId = event.queryStringParameters?.itemId;
const body = JSON.parse(event.body ?? '{}');
if (!itemId) {
return respond(400, { error: 'Missing itemId parameter' });
}
if (!body.status) {
return respond(400, { error: 'Missing status in request body' });
}
const result = await client.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: { userId, itemId },
UpdateExpression: 'SET #status = :status, updatedAt = :updatedAt',
ExpressionAttributeNames: {
'#status': 'status',
},
ExpressionAttributeValues: {
':status': body.status,
':updatedAt': new Date().toISOString(),
},
ReturnValues: 'ALL_NEW',
}),
);
return respond(200, result.Attributes ?? {});
}You’ll also need to add UpdateCommand to your imports from @aws-sdk/lib-dynamodb and add dynamodb:UpdateItem to your IAM policy.
Cleanup
If you want to delete the table after testing:
aws dynamodb delete-table \
--table-name my-frontend-app-data \
--region us-east-1 \
--output jsonDeleting a DynamoDB table is permanent and deletes all data in the table. Only run this if you’re done with the exercise and don’t need the data.