Steve Kinney

Solution: Build an API with API Gateway and Lambda

Here’s the complete solution for every step, including the handler code, all CLI commands, and the expected output at each stage.

Why This Works

  • HTTP API routes hand the whole request to one Lambda function, which keeps the gateway configuration simple and pushes application logic into normal TypeScript code.
  • Lambda permission is the hidden dependency that makes the integration real. Without it, the API exists but cannot invoke anything.
  • The browser test matters because a backend that works in curl but fails on CORS is still broken for a frontend team.

If you want AWS’s version of the route, integration, and CORS workflow open while you work, keep the HTTP APIs documentation, the Lambda integration guide, and the HTTP API CORS guide nearby.

The Handler

lambda/src/handler.ts

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';

interface Item {
  id: string;
  name: string;
  price: number;
}

const items: Item[] = [
  { id: '1', name: 'TypeScript in Action', price: 29.99 },
  { id: '2', name: 'AWS for Humans', price: 34.99 },
  { id: '3', name: 'React Patterns', price: 24.99 },
];

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const method = event.requestContext.http.method;
Note Route by HTTP method since both GET /items and POST /items hit the same Lambda function.
  if (method === 'GET') {
    return {
      statusCode: 200,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items }),
    };
  }

  if (method === 'POST') {
    let body: { name?: string; price?: number };

    try {
      body = JSON.parse(event.body || '{}');
    } catch {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Invalid JSON in request body' }),
      };
    }

    if (!body.name || typeof body.price !== 'number') {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Missing required fields: name (string), price (number)',
        }),
      };
    }

    const newItem: Item = {
      id: crypto.randomUUID(),
      name: body.name,
      price: body.price,
    };

    items.push(newItem);
Note This in-memory array resets on cold starts. A real API would write to DynamoDB.
    return {
      statusCode: 201,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newItem),
    };
  }

  return {
    statusCode: 405,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ error: 'Method not allowed' }),
  };
};

Build and Zip

cd lambda
npm run build
cd dist && zip -r ../function.zip . && cd ..

Expected: dist/handler.js is created with no errors. function.zip contains the compiled output.

Deploy the Lambda Function

Update Existing Function

If you have the function from the Lambda exercise:

aws lambda update-function-code \
  --function-name my-frontend-app-api \
  --zip-file fileb://lambda/function.zip \
  --region us-east-1 \
  --output json

Create New Function

If you’re starting fresh, create the execution role and function first:

aws iam create-role \
  --role-name my-frontend-app-lambda-role \
  --assume-role-policy-document file://trust-policy.json \
  --region us-east-1 \
  --output json

aws iam attach-role-policy \
  --role-name my-frontend-app-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \
  --region us-east-1 \
  --output json

aws lambda create-function \
  --function-name my-frontend-app-api \
  --runtime nodejs20.x \
  --role arn:aws:iam::123456789012:role/my-frontend-app-lambda-role \
  --handler handler.handler \
  --zip-file fileb://lambda/function.zip \
  --region us-east-1 \
  --output json

Verify with Direct Invocation

Test GET:

aws lambda invoke \
  --function-name my-frontend-app-api \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"GET","path":"/items"}}}' \
  --region us-east-1 \
  --output json \
  response.json

cat response.json

Expected response:

{
  "statusCode": 200,
  "headers": { "Content-Type": "application/json" },
  "body": "{\"items\":[{\"id\":\"1\",\"name\":\"TypeScript in Action\",\"price\":29.99},{\"id\":\"2\",\"name\":\"AWS for Humans\",\"price\":34.99},{\"id\":\"3\",\"name\":\"React Patterns\",\"price\":24.99}]}"
}

Test POST:

aws lambda invoke \
  --function-name my-frontend-app-api \
  --cli-binary-format raw-in-base64-out \
  --payload '{"requestContext":{"http":{"method":"POST","path":"/items"}},"body":"{\"name\":\"Node.js Essentials\",\"price\":19.99}"}' \
  --region us-east-1 \
  --output json \
  response.json

cat response.json

Expected response:

{
  "statusCode": 201,
  "headers": { "Content-Type": "application/json" },
  "body": "{\"id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\"name\":\"Node.js Essentials\",\"price\":19.99}"
}

The id will be a different UUID on each invocation.

Create the HTTP API

aws apigatewayv2 create-api \
  --name my-frontend-app-api \
  --protocol-type HTTP \
  --region us-east-1 \
  --output json

Expected output:

{
  "ApiEndpoint": "https://abc123def4.execute-api.us-east-1.amazonaws.com",
  "ApiId": "abc123def4",
  "CreatedDate": "2026-03-18T12:00:00+00:00",
  "Name": "my-frontend-app-api",
  "ProtocolType": "HTTP",
  "RouteSelectionExpression": "${request.method} ${request.path}"
}

Save the API ID:

API_ID="abc123def4"

Create the Integration

aws apigatewayv2 create-integration \
  --api-id $API_ID \
  --integration-type AWS_PROXY \
  --integration-uri arn:aws:lambda:us-east-1:123456789012:function:my-frontend-app-api \
  --payload-format-version 2.0 \
  --region us-east-1 \
  --output json

Expected output:

{
  "ConnectionType": "INTERNET",
  "IntegrationId": "a1b2c3",
  "IntegrationType": "AWS_PROXY",
  "IntegrationUri": "arn:aws:lambda:us-east-1:123456789012:function:my-frontend-app-api",
  "PayloadFormatVersion": "2.0",
  "TimeoutInMillis": 30000
}

Save the integration ID:

INTEGRATION_ID="a1b2c3"

Create the Routes

GET /items

aws apigatewayv2 create-route \
  --api-id $API_ID \
  --route-key "GET /items" \
  --target "integrations/$INTEGRATION_ID" \
  --region us-east-1 \
  --output json

Expected output:

{
  "ApiKeyRequired": false,
  "RouteId": "route-get-123",
  "RouteKey": "GET /items",
  "Target": "integrations/a1b2c3"
}

POST /items

aws apigatewayv2 create-route \
  --api-id $API_ID \
  --route-key "POST /items" \
  --target "integrations/$INTEGRATION_ID" \
  --region us-east-1 \
  --output json

Expected output:

{
  "ApiKeyRequired": false,
  "RouteId": "route-post-456",
  "RouteKey": "POST /items",
  "Target": "integrations/a1b2c3"
}

Verify routes

aws apigatewayv2 get-routes \
  --api-id $API_ID \
  --region us-east-1 \
  --output json

Expected: Two routes with route keys GET /items and POST /items, both targeting the same integration.

In the console, the Routes page shows both routes listed under the API.

The API Gateway Routes page showing the ANY / route created for the my-frontend-api HTTP API.

Grant Permission

aws lambda add-permission \
  --function-name my-frontend-app-api \
  --statement-id apigateway-invoke \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:123456789012:$API_ID/*" \
  --region us-east-1 \
  --output json

Expected output:

{
  "Statement": "{\"Sid\":\"apigateway-invoke\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"apigateway.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:us-east-1:123456789012:function:my-frontend-app-api\",\"Condition\":{\"ArnLike\":{\"AWS:SourceArn\":\"arn:aws:execute-api:us-east-1:123456789012:abc123def4/*\"}}}"
}

Test with curl

curl https://$API_ID.execute-api.us-east-1.amazonaws.com/items

Expected:

{
  "items": [
    { "id": "1", "name": "TypeScript in Action", "price": 29.99 },
    { "id": "2", "name": "AWS for Humans", "price": 34.99 },
    { "id": "3", "name": "React Patterns", "price": 24.99 }
  ]
}

Test POST:

curl -X POST \
  https://$API_ID.execute-api.us-east-1.amazonaws.com/items \
  -H "Content-Type: application/json" \
  -d '{"name":"Node.js Essentials","price":19.99}'

Expected:

{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Node.js Essentials", "price": 19.99 }

If curl returns {"message":"Internal Server Error"}, the Lambda permission is missing. Run the add-permission command above. If the API returns {"message":"Not Found"}, the routes weren’t created correctly—check get-routes to verify.

Configure CORS

aws apigatewayv2 update-api \
  --api-id $API_ID \
  --cors-configuration \
    AllowOrigins="http://localhost:3000","http://localhost:5173" \
    AllowMethods="GET","POST" \
    AllowHeaders="Content-Type" \
    MaxAge=86400 \
  --region us-east-1 \
  --output json

Expected: the response includes a CorsConfiguration block with the values you set.

Test preflight

curl -i -X OPTIONS \
  https://$API_ID.execute-api.us-east-1.amazonaws.com/items \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

Expected headers in the response:

access-control-allow-headers: Content-Type
access-control-allow-methods: GET, POST
access-control-allow-origin: http://localhost:3000
access-control-max-age: 86400

Call from React

Here is a minimal React component that calls both endpoints:

import { useEffect, useState } from 'react';

interface Item {
  id: string;
  name: string;
  price: number;
}

const API_URL = 'https://abc123def4.execute-api.us-east-1.amazonaws.com';

export default function Items() {
  const [items, setItems] = useState<Item[]>([]);
  const [name, setName] = useState('');
  const [price, setPrice] = useState('');

  useEffect(() => {
    fetch(`${API_URL}/items`)
      .then((response) => response.json())
      .then((data) => setItems(data.items));
  }, []);

  async function handleSubmit(event: React.FormEvent) {
    event.preventDefault();

    const response = await fetch(`${API_URL}/items`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, price: parseFloat(price) }),
    });

    const newItem = await response.json();
    setItems((previous) => [...previous, newItem]);
    setName('');
    setPrice('');
  }

  return (
    <div>
      <h1>Items</h1>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            {item.name}—${item.price.toFixed(2)}
          </li>
        ))}
      </ul>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Name"
          value={name}
          onChange={(event) => setName(event.target.value)}
        />
        <input
          type="number"
          placeholder="Price"
          step="0.01"
          value={price}
          onChange={(event) => setPrice(event.target.value)}
        />
        <button type="submit">Add Item</button>
      </form>
    </div>
  );
}

Open the browser, verify the items list loads, add a new item using the form, and confirm it appears in the list. Check the DevTools Network tab to verify both requests succeed with 200/201 status codes and no CORS errors.

The in-memory items array in the Lambda handler resets on every cold start. If you add an item through POST and then wait a few minutes (long enough for Lambda to recycle the execution environment), the item will be gone. This is expected—in-memory state isn’t persistent. In the DynamoDB section, you’ll replace this array with DynamoDB for durable storage.

Stretch Goal: GET /items/{id}

Add the route

aws apigatewayv2 create-route \
  --api-id $API_ID \
  --route-key "GET /items/{id}" \
  --target "integrations/$INTEGRATION_ID" \
  --region us-east-1 \
  --output json

Update the handler

Add this block before the existing if (method === 'GET') check—the more specific route needs to match first:

if (method === 'GET' && event.pathParameters?.id) {
  const item = items.find((item) => item.id === event.pathParameters!.id);

  if (!item) {
    return {
      statusCode: 404,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Item not found' }),
    };
  }

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item),
  };
}

Rebuild, rezip, and update the function code:

cd lambda
npm run build
cd dist && zip -r ../function.zip . && cd ..

aws lambda update-function-code \
  --function-name my-frontend-app-api \
  --zip-file fileb://lambda/function.zip \
  --region us-east-1 \
  --output json

Test:

curl https://$API_ID.execute-api.us-east-1.amazonaws.com/items/1

Expected:

{ "id": "1", "name": "TypeScript in Action", "price": 29.99 }
curl https://$API_ID.execute-api.us-east-1.amazonaws.com/items/999

Expected:

{ "error": "Item not found" }

Cleanup

To remove everything you created in this exercise:

# Delete the API (removes all routes, integrations, and stages)
aws apigatewayv2 delete-api \
  --api-id $API_ID \
  --region us-east-1 \
  --output json

# Remove the Lambda permission
aws lambda remove-permission \
  --function-name my-frontend-app-api \
  --statement-id apigateway-invoke \
  --region us-east-1 \
  --output json

The Lambda function and execution role aren’t deleted—you’ll use them again in later modules.

Last modified on .