Table of Contents
- Introduction
- Why API Gateway + Lambda?
- What We're Building
- Project Setup
- Creating Lambda Functions
- Building the API Gateway
- CORS Configuration Deep Dive
- Deployment
- Testing Your API
- Connecting to the Frontend
- Gotchas & Common Pitfalls
- Best Practices
- Cost Considerations
- Conclusion
- References
Introduction
Quick Links:
- π Source Code: GitHub Repository
- π Live Demo: https://dmcechq7isaw7.cloudfront.net/
In Part 1, we launched a beautiful contact form website using S3 and CloudFront. But there's a problem β our form doesn't actually do anything yet. Click "Send Message" and... nothing happens. The data just vanishes into the void.
Today, we're going to change that.
Imagine you're building a lead generation system for your business. Every contact matters. You need a reliable, scalable backend that can handle one request or a million β without breaking a sweat or your budget. That's exactly what API Gateway and Lambda deliver.
In this second part of our Serverless Web Mastery series, we'll transform our static website into a dynamic application by creating a fully-functional REST API. By the end of this post, you'll have an API that can create, read, update, and delete leads β all without managing a single server.
Let's make that form come alive!
Why API Gateway + Lambda?
Before diving into code, let's understand why this combination is so powerful:
π API Gateway Benefits
| Feature | What It Does |
|---|---|
| Managed Infrastructure | No servers to patch or scale |
| Built-in Security | API keys, throttling, WAF integration |
| Request Validation | Validate requests before hitting Lambda |
| CORS Handling | Browser security headers, automatic OPTIONS |
| Monitoring | CloudWatch integration out of the box |
β‘ Lambda Benefits
| Feature | What It Does |
|---|---|
| Pay-per-use | Billed only when your code runs |
| Auto-scaling | 0 to thousands of concurrent executions |
| No Cold Servers | No idle instances burning money |
| Multiple Runtimes | Node.js, Python, Go, Rust, and more |
| Integration | Direct access to 200+ AWS services |
π° The Cost Advantage
Traditional server: ~$40-100/month (even when idle)
Serverless (1000 requests/day): ~$0.50/month
That's a 99% cost reduction for most small to medium workloads!
What We're Building
Our Contact Form API will have these endpoints:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Endpoints β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β GET /health β Health check (monitoring) β
β POST /leads β Create a new lead β
β GET /leads β List all leads (admin) β
β GET /leads/{id} β Get specific lead β
β PUT /leads/{id} β Update a lead β
β DELETE /leads/{id} β Delete a lead β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Architecture Overview:
βββββββββββββββββββ
β CloudFront β
β (Part 1 Site) β
ββββββββββ¬βββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Gateway β
β (REST API) β
β β
β β’ CORS configuration β
β β’ Request throttling β
β β’ CloudWatch logging β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββΌββββββββββββββββββ
βΌ βΌ βΌ
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Health β β Leads β β Future β
β Handler β β Handler β β Handlers β
β (Lambda) β β (Lambda) β β (Lambda) β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
Project Setup
Let's create the Part 2 directory structure:
cd aws-serverless-website-tutorial
mkdir -p part-2-api-lambda/{cdk,lambda/handlers}
cd part-2-api-lambda/cdk
Create package.json:
{
"name": "api-lambda-cdk",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"deploy": "cdk deploy",
"destroy": "cdk destroy"
},
"dependencies": {
"aws-cdk-lib": "^2.170.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "~5.7.0",
"aws-cdk": "^2.170.0"
}
}
Install dependencies:
npm install
Creating Lambda Functions
Health Check Handler
A simple health check is essential for monitoring. Create lambda/handlers/health.ts:
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from "aws-lambda";
interface HealthResponse {
status: "healthy" | "degraded" | "unhealthy";
timestamp: string;
version: string;
region: string;
}
export const handler = async (
event: APIGatewayProxyEvent,
context: Context,
): Promise<APIGatewayProxyResult> => {
console.log("Health check requested", {
requestId: context.awsRequestId,
});
const response: HealthResponse = {
status: "healthy",
timestamp: new Date().toISOString(),
version: "1.0.0",
region: process.env.AWS_REGION || "unknown",
};
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
body: JSON.stringify(response),
};
};
Why a health endpoint?
- Load balancers need it
- Monitoring systems poll it
- Quick verification during deployment
Leads Handler
Now for the main event β our leads CRUD handler. Create lambda/handlers/leads.ts:
import {
APIGatewayProxyEvent,
APIGatewayProxyResult,
Context,
} from "aws-lambda";
// Lead interface
interface Lead {
id: string;
name: string;
email: string;
company?: string;
subject: string;
message: string;
status: "new" | "contacted" | "qualified" | "converted";
createdAt: string;
updatedAt: string;
}
// In-memory storage (replaced with DynamoDB in Part 3)
const leadsStorage: Map<string, Lead> = new Map();
// Generate unique ID
function generateId(): string {
return `lead_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Validate create request
function validateCreateRequest(body: any): { valid: boolean; error?: string } {
if (!body.name || body.name.length < 2) {
return { valid: false, error: "Name is required (min 2 characters)" };
}
if (!body.email || !body.email.includes("@")) {
return { valid: false, error: "Valid email is required" };
}
if (!body.message || body.message.length < 10) {
return { valid: false, error: "Message is required (min 10 characters)" };
}
return { valid: true };
}
// Create standardized response
function createResponse(
statusCode: number,
body: object,
): APIGatewayProxyResult {
return {
statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
},
body: JSON.stringify(body),
};
}
// Main handler
export const handler = async (
event: APIGatewayProxyEvent,
context: Context,
): Promise<APIGatewayProxyResult> => {
const { httpMethod, pathParameters, body } = event;
const leadId = pathParameters?.id;
console.log("Request:", {
method: httpMethod,
leadId,
requestId: context.awsRequestId,
});
try {
switch (httpMethod) {
// Create new lead
case "POST": {
const parsedBody = JSON.parse(body || "{}");
const validation = validateCreateRequest(parsedBody);
if (!validation.valid) {
return createResponse(400, {
success: false,
error: validation.error,
});
}
const now = new Date().toISOString();
const lead: Lead = {
id: generateId(),
name: parsedBody.name.trim(),
email: parsedBody.email.toLowerCase().trim(),
company: parsedBody.company?.trim(),
subject: parsedBody.subject || "general",
message: parsedBody.message.trim(),
status: "new",
createdAt: now,
updatedAt: now,
};
leadsStorage.set(lead.id, lead);
console.log("Lead created:", lead.id);
return createResponse(201, {
success: true,
data: lead,
message: "Lead created successfully",
});
}
// List all leads
case "GET": {
if (leadId) {
const lead = leadsStorage.get(leadId);
if (!lead) {
return createResponse(404, {
success: false,
error: "Lead not found",
});
}
return createResponse(200, { success: true, data: lead });
}
const leads = Array.from(leadsStorage.values());
return createResponse(200, {
success: true,
data: leads,
message: `Found ${leads.length} lead(s)`,
});
}
// Update lead
case "PUT": {
if (!leadId) {
return createResponse(400, {
success: false,
error: "Lead ID required",
});
}
const lead = leadsStorage.get(leadId);
if (!lead) {
return createResponse(404, {
success: false,
error: "Lead not found",
});
}
const updates = JSON.parse(body || "{}");
const updatedLead: Lead = {
...lead,
...updates,
updatedAt: new Date().toISOString(),
};
leadsStorage.set(leadId, updatedLead);
return createResponse(200, { success: true, data: updatedLead });
}
// Delete lead
case "DELETE": {
if (!leadId) {
return createResponse(400, {
success: false,
error: "Lead ID required",
});
}
const deleted = leadsStorage.delete(leadId);
if (!deleted) {
return createResponse(404, {
success: false,
error: "Lead not found",
});
}
return createResponse(200, { success: true, message: "Lead deleted" });
}
default:
return createResponse(405, {
success: false,
error: "Method not allowed",
});
}
} catch (error) {
console.error("Error:", error);
return createResponse(500, {
success: false,
error: "Internal server error",
});
}
};
Important Note: This uses in-memory storage for simplicity. In Part 3, we'll replace this with DynamoDB for persistent storage.
Building the API Gateway
Now let's wire everything together with CDK. Create lib/api-lambda-stack.ts:
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as logs from "aws-cdk-lib/aws-logs";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import * as path from "path";
export class ApiLambdaStack extends cdk.Stack {
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ============================================
// Lambda Functions
// ============================================
// Health Check Handler
const healthHandler = new lambdaNodejs.NodejsFunction(
this,
"HealthHandler",
{
runtime: lambda.Runtime.NODEJS_22_X,
entry: path.join(__dirname, "../../lambda/handlers/health.ts"),
handler: "handler",
description: "Health check endpoint",
timeout: cdk.Duration.seconds(10),
memorySize: 128,
logRetention: logs.RetentionDays.ONE_WEEK,
// Environment variables
environment: {
NODE_ENV: "production",
LOG_LEVEL: "INFO",
},
bundling: {
minify: true,
sourceMap: true,
},
},
);
// Leads Handler
const leadsHandler = new lambdaNodejs.NodejsFunction(this, "LeadsHandler", {
runtime: lambda.Runtime.NODEJS_22_X,
entry: path.join(__dirname, "../../lambda/handlers/leads.ts"),
handler: "handler",
description: "Leads CRUD operations",
timeout: cdk.Duration.seconds(30),
memorySize: 256,
logRetention: logs.RetentionDays.ONE_WEEK,
environment: {
NODE_ENV: "production",
LOG_LEVEL: "INFO",
},
bundling: {
minify: true,
sourceMap: true,
},
});
// ============================================
// API Gateway
// ============================================
this.api = new apigateway.RestApi(this, "ContactFormApi", {
restApiName: "Contact Form API",
description: "Serverless API for contact form",
deployOptions: {
stageName: "prod",
loggingLevel: apigateway.MethodLoggingLevel.INFO,
metricsEnabled: true,
throttlingBurstLimit: 100,
throttlingRateLimit: 50,
},
// CORS - Critical for browser requests!
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
allowHeaders: ["Content-Type", "Authorization"],
allowCredentials: true,
},
});
// ============================================
// API Routes
// ============================================
// Health endpoint: GET /health
const apiResource = this.api.root.addResource('api');
const healthResource = apiResource.addResource('health');
healthResource.addMethod('GET', new apigateway.LambdaIntegration(healthHandler, {
proxy: true, // Use Lambda Proxy Integration
}));
// Leads endpoints
const leadsResource = apiResource.addResource('leads');
// POST /leads - Create a new lead
leadsResource.addMethod('POST', new apigateway.LambdaIntegration(this.leadsHandler, {
proxy: true,
}));
// GET /leads - List all leads
leadsResource.addMethod('GET', new apigateway.LambdaIntegration(this.leadsHandler, {
proxy: true,
}));
// Single lead endpoints: /leads/{id}
const leadByIdResource = leadsResource.addResource('{id}');
// GET /leads/{id} - Get specific lead
leadByIdResource.addMethod('GET', new apigateway.LambdaIntegration(this.leadsHandler, {
proxy: true,
}));
// PUT /leads/{id} - Update a lead
leadByIdResource.addMethod('PUT', new apigateway.LambdaIntegration(this.leadsHandler, {
proxy: true,
}));
// DELETE /leads/{id} - Delete a lead
leadByIdResource.addMethod('DELETE', new apigateway.LambdaIntegration(this.leadsHandler, {
proxy: true,
}));
// ============================================
// Outputs
// ============================================
new cdk.CfnOutput(this, "ApiEndpoint", {
value: this.api.url,
description: "API Gateway endpoint URL",
});
}
}
Deep Dive: Proxy vs. Non-Proxy Integration
You'll notice we used { proxy: true } in our Lambda integration. What does this mean?
Lambda Proxy Integration (Recommended)
- How it works: API Gateway passes the entire HTTP request (headers, body, query params) to your Lambda function as a JSON object.
-
Your Responsibility: Your Lambda must return a response in a specific format:
{ statusCode: 200, body: "..." }. - Pros: Full control over the response, access to all request details, easier to test locally.
Non-Proxy Integration
- How it works: API Gateway transforms the incoming request using "Mapping Templates" before sending it to Lambda, and transforms the Lambda's response before sending it back to the client.
- Pros: Decouples your backend logic from HTTP details.
- Cons: Velocity Template Language (VTL) is hard to debug and maintain.
For modern serverless applications, Proxy Integration is the standard because it keeps the infrastructure simple (no VTL) and moves the logic into the code (TypeScript), which is where we want it!
CORS Configuration Deep Dive
CORS (Cross-Origin Resource Sharing) is often the #1 source of frustration when building APIs. Let me save you hours of debugging:
What is CORS?
When your frontend (hosted on d123.cloudfront.net) makes a request to your API (abc.execute-api.amazonaws.com), browsers block this by default. CORS headers tell the browser "it's okay, allow this request."
What Headers Do We Need?
'Access-Control-Allow-Origin': '*' // or specific domain
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
'Access-Control-Allow-Headers': 'Content-Type,Authorization'
'Access-Control-Allow-Credentials': 'true' // if using cookies
API Gateway CORS vs Lambda CORS
You need CORS in both places:
- API Gateway - Handles OPTIONS preflight requests
- Lambda Response - Includes headers in actual responses
// In CDK (API Gateway level)
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
}
// In Lambda (Response level)
return {
headers: {
'Access-Control-Allow-Origin': '*',
},
// ... rest of response
};
Production Tip
Replace * with your specific domain:
allowOrigins: ['https://yourdomain.com'],
Deployment
Deploy your API:
cd part-2-api-lambda/cdk
npm install
npx cdk deploy
You'll see outputs like:
β
ContactFormApiStack
Outputs:
ContactFormApiStack.ApiEndpoint = https://abc123xyz.execute-api.us-east-1.amazonaws.com/prod/
π Your API is live!
Testing Your API
Using curl
# Health check
curl https://YOUR_API/health
# Create a lead
curl -X POST https://YOUR_API/leads \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"subject": "general",
"message": "Testing the API!"
}'
# List leads
curl https://YOUR_API/leads
Using Postman
Import this collection for easy testing:
- Create new request
- Set URL to your API endpoint
- Set method (GET, POST, etc.)
- For POST/PUT, add JSON body
- Send!
Connecting to the Frontend
Update your Part 1 frontend app.js:
const CONFIG = {
// Replace with your actual API endpoint
API_ENDPOINT: "https://abc123xyz.execute-api.us-east-1.amazonaws.com/prod",
};
async function submitToAPI(formData) {
const response = await fetch(`${CONFIG.API_ENDPOINT}/leads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to submit form");
}
return response.json();
}
Now your form actually works! π
Gotchas & Common Pitfalls
1. CORS Errors
Problem: "Access to fetch has been blocked by CORS policy"
Solution: Check both:
- API Gateway CORS configuration
- Lambda response headers
// Make sure both have matching headers!
2. 502 Bad Gateway
Problem: API returns 502 error
Causes:
- Lambda threw an unhandled exception
- Lambda timed out
- Response format incorrect
Solution: Check CloudWatch logs:
aws logs tail /aws/lambda/YourFunctionName --follow
3. Lambda Proxy Integration Response Format
Problem: API Gateway doesn't understand Lambda response
Solution: Always return this exact format:
return {
statusCode: 200, // Number, not string!
headers: { ... },
body: JSON.stringify({ ... }), // Must be string!
};
4. Path Parameters Not Working
Problem: pathParameters is null or undefined
Solution: Ensure the path parameter is defined in API Gateway:
const leadById = leads.addResource("{id}"); // Note the curly braces!
Best Practices
1. Use Lambda Proxy Integration
Always use proxy integration for flexibility:
new apigateway.LambdaIntegration(handler, {
proxy: true, // This is the default, but be explicit
});
2. Implement Request Validation
Validate early, fail fast:
function validateRequest(body: any): { valid: boolean; error?: string } {
if (!body.email) return { valid: false, error: "Email required" };
if (!isValidEmail(body.email))
return { valid: false, error: "Invalid email" };
// ... more validations
return { valid: true };
}
3. Consistent Error Responses
Use a standard error format:
interface ErrorResponse {
success: false;
error: string;
code?: string;
}
4. Add Request Throttling
Protect your API from abuse:
deployOptions: {
throttlingBurstLimit: 100, // Max concurrent requests
throttlingRateLimit: 50, // Requests per second
}
5. Enable CloudWatch Logging
Debugging is impossible without logs:
logRetention: logs.RetentionDays.ONE_WEEK,
Cost Considerations
Let's break down the costs:
| Service | Free Tier | Cost After |
|---|---|---|
| API Gateway | 1M requests/month | $3.50 per million |
| Lambda Requests | 1M requests/month | $0.20 per million |
| Lambda Duration | 400K GB-seconds | ~$0.0000167/GB-second |
| CloudWatch Logs | 5GB ingestion/month | $0.50/GB |
Real-World Example
For a contact form receiving 1,000 submissions/day:
- API Gateway: 30K requests/month β Free
- Lambda: 30K invocations, ~100ms each β Free
- Total: $0/month (within free tier!)
Even at 100K submissions/day, you're looking at ~$5/month.
Conclusion
Congratulations! π You've built a production-ready REST API with:
β
Multiple Lambda functions
β
RESTful API Gateway endpoints
β
CORS configuration for browser access
β
Input validation
β
Error handling
β
CloudWatch logging
β
Request throttling
But wait β there's a catch. Our current implementation uses in-memory storage, which means data is lost when Lambda recycles.
In Part 3, we'll fix this by adding DynamoDB for persistent storage. You'll learn:
- DynamoDB table design
- CRUD operations with AWS SDK v3
- Batch operations for performance (check out my DynamoDB Batch Operations blog)
- Proper error handling and retries
If you're stuck on any CDK issues, my AWS CDK Debugging Guide has got you covered.
GitHub Repository: aws-serverless-website-tutorial
See you until next time. Happy coding! π
Top comments (0)