Building Serverless APIs with AWS Lambda and API Gateway
Serverless APIs eliminate server management while providing automatic scaling and pay-per-use pricing. After building several serverless APIs in production, here’s how to build them right.
Architecture Overview
Client → API Gateway → Lambda → DynamoDB/S3/etc
(Routing) (Logic) (Data)
API Gateway handles:
- Request routing
- Authentication/authorization
- Rate limiting
- Request/response transformation
Lambda handles:
- Business logic
- Data processing
- External API calls
Basic Lambda Function
// handler.js
exports.handler = async (event) => {
// Parse request
const { httpMethod, path, body, headers } = event;
// Route based on method and path
if (httpMethod === 'GET' && path === '/users') {
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
users: await getUsers()
})
};
}
if (httpMethod === 'POST' && path === '/users') {
const userData = JSON.parse(body);
const user = await createUser(userData);
return {
statusCode: 201,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ user })
};
}
return {
statusCode: 404,
body: JSON.stringify({ error: 'Not found' })
};
};
API Gateway Configuration
SAM Template
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
ApiFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handler.handler
Runtime: nodejs12.x
Events:
ApiEvent:
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
RestApiId: !Ref ApiGateway
ApiGateway:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Cors:
AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,X-Amz-Date,Authorization'"
AllowOrigin: "'*'"
Serverless Framework
# serverless.yml
service: my-api
provider:
name: aws
runtime: nodejs12.x
region: us-east-1
environment:
TABLE_NAME: ${self:custom.tableName}
functions:
api:
handler: handler.handler
events:
- http:
path: /{proxy+}
method: ANY
cors: true
resources:
Resources:
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:custom.tableName}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
RESTful API Structure
Route Handler
// routes/users.js
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME;
async function getUsers() {
const result = await dynamodb.scan({
TableName: TABLE_NAME
}).promise();
return result.Items;
}
async function getUserById(userId) {
const result = await dynamodb.get({
TableName: TABLE_NAME,
Key: { id: userId }
}).promise();
return result.Item;
}
async function createUser(userData) {
const user = {
id: generateId(),
...userData,
createdAt: new Date().toISOString()
};
await dynamodb.put({
TableName: TABLE_NAME,
Item: user
}).promise();
return user;
}
function generateId() {
return Math.random().toString(36).substring(2, 15);
}
module.exports = {
getUsers,
getUserById,
createUser
};
Main Handler
// handler.js
const { getUsers, getUserById, createUser } = require('./routes/users');
exports.handler = async (event) => {
const { httpMethod, pathParameters, body } = event;
const route = httpMethod + ' ' + (pathParameters?.proxy || '');
try {
let response;
switch (route) {
case 'GET /users':
response = await getUsers();
return successResponse(response);
case 'GET /users/{id}':
const userId = pathParameters.proxy.split('/')[1];
response = await getUserById(userId);
return response
? successResponse(response)
: notFoundResponse();
case 'POST /users':
const userData = JSON.parse(body);
response = await createUser(userData);
return successResponse(response, 201);
default:
return notFoundResponse();
}
} catch (error) {
console.error('Error:', error);
return errorResponse(error);
}
};
function successResponse(data, statusCode = 200) {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(data)
};
}
function errorResponse(error, statusCode = 500) {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
error: error.message || 'Internal server error'
})
};
}
function notFoundResponse() {
return {
statusCode: 404,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ error: 'Not found' })
};
}
Authentication
API Keys
// Check API key
function validateApiKey(event) {
const apiKey = event.headers['x-api-key'];
const validKeys = process.env.API_KEYS.split(',');
return validKeys.includes(apiKey);
}
exports.handler = async (event) => {
if (!validateApiKey(event)) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' })
};
}
// Process request
};
JWT Authentication
const jwt = require('jsonwebtoken');
function verifyToken(event) {
const token = event.headers.Authorization?.replace('Bearer ', '');
if (!token) {
throw new Error('No token provided');
}
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
throw new Error('Invalid token');
}
}
exports.handler = async (event) => {
try {
const user = verifyToken(event);
event.user = user; // Add user to event
// Process request with user context
return successResponse({ message: 'Authenticated' });
} catch (error) {
return errorResponse(error, 401);
}
};
Error Handling
class ApiError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
class NotFoundError extends ApiError {
constructor(resource) {
super(404, `${resource} not found`);
}
}
class ValidationError extends ApiError {
constructor(message) {
super(400, message);
}
}
exports.handler = async (event) => {
try {
// Your logic
if (!user) {
throw new NotFoundError('User');
}
if (!isValid(userData)) {
throw new ValidationError('Invalid user data');
}
return successResponse(result);
} catch (error) {
if (error instanceof ApiError) {
return errorResponse(error, error.statusCode);
}
// Log unexpected errors
console.error('Unexpected error:', error);
return errorResponse(new Error('Internal server error'), 500);
}
};
Request Validation
const Joi = require('joi');
const createUserSchema = Joi.object({
name: Joi.string().required().min(2).max(100),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).max(120).optional()
});
exports.handler = async (event) => {
if (event.httpMethod === 'POST' && event.path === '/users') {
const userData = JSON.parse(event.body);
const { error, value } = createUserSchema.validate(userData);
if (error) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Validation failed',
details: error.details
})
};
}
// Use validated value
const user = await createUser(value);
return successResponse(user, 201);
}
};
CORS Configuration
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS'
};
exports.handler = async (event) => {
// Handle preflight
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 200,
headers: corsHeaders,
body: ''
};
}
// Handle actual request
return {
statusCode: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json'
},
body: JSON.stringify({ data: 'response' })
};
};
Deployment
Using Serverless Framework
# Install
npm install -g serverless
# Deploy
serverless deploy
# Deploy specific function
serverless deploy function -f api
# View logs
serverless logs -f api --tail
Using AWS SAM
# Build
sam build
# Deploy
sam deploy --guided
# Test locally
sam local start-api
Monitoring
// Custom metrics
const CloudWatch = require('aws-sdk/clients/cloudwatch');
const cloudwatch = new CloudWatch();
async function putMetric(metricName, value, unit = 'Count') {
await cloudwatch.putMetricData({
Namespace: 'MyAPI',
MetricData: [{
MetricName: metricName,
Value: value,
Unit: unit,
Timestamp: new Date()
}]
}).promise();
}
exports.handler = async (event) => {
const startTime = Date.now();
try {
const result = await processRequest(event);
// Record success metric
await putMetric('Requests', 1);
await putMetric('RequestDuration', Date.now() - startTime, 'Milliseconds');
return successResponse(result);
} catch (error) {
// Record error metric
await putMetric('Errors', 1);
throw error;
}
};
Best Practices
- Keep functions focused - One function per resource/operation
- Use environment variables - For configuration
- Implement proper error handling - Return appropriate status codes
- Validate input - Use schemas
- Enable CORS - For web clients
- Use API Gateway features - Rate limiting, caching
- Monitor performance - Track metrics
- Version your API - Use API Gateway stages
Conclusion
Serverless APIs with Lambda and API Gateway provide:
- Automatic scaling
- Pay-per-use pricing
- No server management
- Built-in features (auth, rate limiting)
Start simple, add complexity as needed. The patterns shown here handle millions of requests in production.
Serverless API patterns from April 2017, using AWS Lambda and API Gateway.