🚀 The fast lane to building Express-style APIs on AWS Lambda - Type-safe REST APIs with zero dependencies.
Building REST APIs on AWS Lambda usually means writing messy routing code with nested if/else statements. Lambda-expressway brings the elegance of Express.js to serverless, with production-grade features built-in.
Before lambda-expressway:
export const handler = async (event: APIGatewayProxyEvent) => {
if (event.path === '/users' && event.httpMethod === 'GET') {
// handle GET users
} else if (event.path.match(/^\/users\/\w+$/) && event.httpMethod === 'GET') {
// manually extract ID, validate, handle errors...
} else if (event.path === '/users' && event.httpMethod === 'POST') {
// manually parse body, validate, handle errors...
}
// ... dozens more routes
return { statusCode: 404, body: 'Not found' };
};With lambda-expressway:
const router = new Router({ cors: true, logging: true });
router.get('/users', getUsers);
router.get('/users/:id', getUserById);
router.post('/users', {
body: z.object({ name: z.string(), email: z.string().email() }),
handler: createUser
});
export const handler = router.handler();- 🎯 Zero Dependencies - No bloat. Only optional Zod for validation
- ⚡ Performance - <5ms overhead, optimized for Lambda cold starts
- 🔒 Type-Safe - Full TypeScript support with automatic type inference from schemas
- 🛡️ Production-Ready - Built-in CORS, rate limiting, logging, and error handling
- 🚀 Developer Experience - Express-like API you already know
- 🔌 Flexible - Works with your existing custom or Winston/Pino logger, no lock-in
vs. Other Lambda Routers:
- TypeScript-First: Built from the ground up with TypeScript, not JavaScript with types added later
- Zod Integration: Automatic request validation with full type inference
- Express Syntax: Uses
:idpath params (not{id}), making migration from Express seamless - Production Features: Rate limiting, custom loggers, route groups - all built-in
- Zero Dependencies: Core has no dependencies; Zod is an optional peer dependency
This package is not published to npm. You can install it directly from GitHub:
npm install github:utsabpanta/lambda-expressway zodOr with yarn:
yarn add github:utsabpanta/lambda-expressway zodOr with pnpm:
pnpm add github:utsabpanta/lambda-expressway zod# From main branch
npm install github:utsabpanta/lambda-expressway#main
# From a specific tag
npm install github:utsabpanta/lambda-expressway#v1.0.0
# From a specific commit
npm install github:utsabpanta/lambda-expressway#abc1234Add this to your package.json:
{
"dependencies": {
"lambda-expressway": "github:utsabpanta/lambda-expressway",
"zod": "^3.22.0"
}
}Then run npm install.
npm install git+https://github.com/utsabpanta/lambda-expressway.gitFor local development and testing:
# Clone the repository
git clone https://github.com/utsabpanta/lambda-expressway.git
cd lambda-expressway
# Install dependencies and build
npm install
npm run build
# Link for local development
npm link
# In your project directory
npm link lambda-expresswayNote: When installing from GitHub, npm will automatically run the build step if you have a prepare script in package.json.
import { Router, z } from 'lambda-expressway';
const router = new Router({
cors: true,
logging: { level: 'info' },
rateLimit: { max: 100, window: 60000 },
});
// Simple GET
router.get('/health', async () => ({ status: 'ok' }));
// Path parameters
router.get('/users/:id', async (req) => {
return { user: await getUser(req.params.id) };
});
// With validation
router.post('/users', {
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
handler: async (req) => {
// req.body is typed and validated automatically
return { user: await createUser(req.body) };
},
});
export const handler = router.handler();Validate request body, params, and query with automatic TypeScript inference:
router.post('/users/:id', {
params: z.object({ id: z.string().uuid() }),
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().min(18).optional(),
}),
query: z.object({
notify: z.enum(['true', 'false']).optional(),
}),
handler: async (req) => {
// All validated and typed!
const { id } = req.params; // string (UUID)
const { name, email, age } = req.body; // all properly typed
const { notify } = req.query; // 'true' | 'false' | undefined
},
});Invalid requests automatically return 400 with validation details.
// Custom authentication middleware (use a proper JWT library!)
import jwt from 'jsonwebtoken';
const auth = async (req, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError('Missing token');
try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
await next();
} catch (err) {
throw new UnauthorizedError('Invalid token');
}
};
// Apply to specific routes
router.get('/protected', auth, async (req) => {
return { user: req.user };
});
// Or globally
router.use(auth);
// Built-in middleware
import { requestId, timeout, bodyLimit } from 'lambda-expressway';
router.get('/test', requestId(), timeout(5000), handler);Pre-built error classes with proper HTTP status codes:
import { NotFoundError, UnauthorizedError, ValidationError } from 'lambda-expressway';
router.get('/users/:id', async (req) => {
const user = await getUser(req.params.id);
if (!user) throw new NotFoundError('User not found');
return { user };
});
// Errors are automatically caught and formatted
// { "error": "User not found" } with statusCode: 404Simple or advanced CORS configuration:
// Simple
const router = new Router({ cors: true });
// Advanced
const router = new Router({
cors: {
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 86400,
},
});Protect your API with in-memory rate limiting:
const router = new Router({
rateLimit: {
max: 100, // 100 requests
window: 60000, // per 60 seconds
keyGenerator: (req) => req.user?.id || req.event.requestContext?.identity?.sourceIp,
},
});Returns 429 when exceeded, with headers:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Use your own logger (Winston, Pino, AWS Powertools, etc.):
import winston from 'winston';
const logger = winston.createLogger({ /* config */ });
const router = new Router({
logger: {
info: (msg, meta) => logger.info(msg, meta),
warn: (msg, meta) => logger.warn(msg, meta),
error: (msg, err, meta) => logger.error(msg, { ...meta, error: err }),
debug: (msg, meta) => logger.debug(msg, meta),
},
});Or use the built-in logger:
const router = new Router({
logging: {
level: 'info', // 'none' | 'error' | 'warn' | 'info' | 'debug'
logRequest: true,
logResponse: true,
logErrors: true,
},
});Organize routes with prefixes:
const api = router.group('/api/v1');
api.get('/users', getUsers); // /api/v1/users
api.post('/users', createUser); // /api/v1/users
const admin = api.group('/admin');
admin.get('/settings', getSettings); // /api/v1/admin/settingsimport { Router, z, UnauthorizedError, NotFoundError } from 'lambda-expressway';
import jwt from 'jsonwebtoken';
const router = new Router({
cors: true,
logging: { level: 'info' },
rateLimit: { max: 100, window: 60000 },
});
// Auth middleware with proper JWT verification
const auth = async (req, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError('Missing token');
try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
await next();
} catch (err) {
throw new UnauthorizedError('Invalid token');
}
};
// Public routes
router.get('/health', async () => ({ status: 'ok' }));
// Protected routes
const api = router.group('/api/v1');
api.get('/users', auth, async () => {
return { users: await getUsers() };
});
api.get('/users/:id', {
middleware: [auth],
params: z.object({ id: z.string().uuid() }),
handler: async (req) => {
const user = await getUser(req.params.id);
if (!user) throw new NotFoundError();
return { user };
},
});
api.post('/users', {
middleware: [auth],
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}),
handler: async (req) => {
const user = await createUser(req.body);
return { user };
},
});
export const handler = router.handler();import {
createSuccessResponse,
createCreatedResponse,
createNoContentResponse,
createBinaryResponse,
createRedirectResponse,
createHtmlResponse,
addHeaders,
addCacheHeaders,
} from 'lambda-expressway';
// Standard responses
return createSuccessResponse({ data: 'value' });
return createCreatedResponse({ id: '123' }, '/users/123');
return createNoContentResponse();
// Binary/special responses
return createBinaryResponse(buffer, 'image/png');
return createRedirectResponse('https://example.com', 301);
return createHtmlResponse('<html>...</html>');
// With headers/cache
let response = createSuccessResponse({ data });
response = addHeaders(response, { 'X-Custom': 'value' });
response = addCacheHeaders(response, 3600); // 1 hourFull type inference from Zod schemas:
const createUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
router.post('/users', {
body: createUserSchema,
handler: async (req) => {
req.body.name; // ✓ TypeScript knows this is string
req.body.age; // ✗ TypeScript error: Property 'age' does not exist
},
});1. Create your Lambda handler with lambda-expressway:
// src/handler.ts
import { Router, z } from 'lambda-expressway';
const router = new Router({
cors: true,
logging: { level: 'info' },
});
router.get('/users', async () => {
return { users: [] };
});
router.post('/users', {
body: z.object({
name: z.string(),
email: z.string().email(),
}),
handler: async (req) => {
return { user: req.body };
},
});
export const handler = router.handler();2. Configure serverless.yml:
# serverless.yml
service: my-api
provider:
name: aws
runtime: nodejs18.x
stage: ${opt:stage, 'dev'}
region: us-east-1
functions:
api:
handler: dist/handler.handler
events:
- httpApi:
path: /{proxy+}
method: any
plugins:
- serverless-esbuild # For TypeScript bundling3. Deploy:
npm run build
serverless deploy1. Create your Lambda handler with lambda-expressway:
// src/app.ts
import { Router } from 'lambda-expressway';
const router = new Router({ cors: true });
router.get('/health', async () => ({ status: 'ok' }));
router.get('/users/:id', async (req) => ({
user: { id: req.params.id }
}));
export const lambdaHandler = router.handler();2. Configure SAM template:
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Timeout: 30
Runtime: nodejs18.x
Resources:
ApiFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: dist/
Handler: app.lambdaHandler
Events:
ApiProxy:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: "es2020"
EntryPoints:
- app.ts
Outputs:
ApiUrl:
Description: "API Gateway endpoint URL"
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"3. Build and deploy:
sam build
sam deploy --guided1. Create your Lambda handler with lambda-expressway:
// lambda/handler.ts
import { Router, z } from 'lambda-expressway';
const router = new Router({
cors: { origin: ['https://yourdomain.com'] },
rateLimit: { max: 100, window: 60000 },
});
router.get('/api/users', async () => ({ users: [] }));
router.post('/api/users', {
body: z.object({ name: z.string(), email: z.string().email() }),
handler: async (req) => ({ user: req.body }),
});
export const handler = router.handler();2. Create CDK stack:
// lib/api-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-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
export class ApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Lambda function with lambda-expressway
const apiFunction = new NodejsFunction(this, 'ApiFunction', {
entry: 'lambda/handler.ts',
handler: 'handler',
runtime: lambda.Runtime.NODEJS_18_X,
bundling: {
externalModules: [],
minify: true,
},
environment: {
NODE_ENV: 'production',
},
});
// HTTP API Gateway
const httpApi = new apigateway.HttpApi(this, 'HttpApi', {
defaultIntegration: new integrations.HttpLambdaIntegration(
'ApiIntegration',
apiFunction
),
});
new cdk.CfnOutput(this, 'ApiUrl', {
value: httpApi.url ?? 'No URL',
description: 'API Gateway URL',
});
}
}3. Deploy:
npm run build
cdk deploy- ✅ Use
/{proxy+}path to route all requests to your Lambda - ✅ Set
method: anyto handle all HTTP methods - ✅ Lambda-expressway handles all routing internally
- ✅ Build TypeScript before deploying (
npm run buildor use bundlers) - ✅ The handler exports a single function that processes all routes
Lambda-expressway provides the middleware pattern but does not include authentication middleware. You should implement authentication using established, secure libraries:
JWT Authentication:
import jwt from 'jsonwebtoken';
import { UnauthorizedError } from 'lambda-expressway';
const jwtAuth = async (req, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError('Missing token');
try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
await next();
} catch (err) {
throw new UnauthorizedError('Invalid token');
}
};
router.get('/protected', jwtAuth, handler);AWS Cognito:
import { CognitoJwtVerifier } from 'aws-jwt-verify';
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.USER_POOL_ID!,
tokenUse: 'access',
clientId: process.env.CLIENT_ID!,
});
const cognitoAuth = async (req, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError();
try {
req.user = await verifier.verify(token);
await next();
} catch (err) {
throw new UnauthorizedError('Invalid token');
}
};API Key Authentication:
const apiKeyAuth = async (req, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey || !isValidApiKey(apiKey)) {
throw new UnauthorizedError('Invalid API key');
}
await next();
};Always validate user input with Zod schemas to prevent injection attacks:
router.post('/users', {
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
}),
handler: async (req) => {
// req.body is validated and safe to use
},
});Configure CORS properly for production:
const router = new Router({
cors: {
origin: ['https://yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 86400,
},
});Protect your API from abuse:
const router = new Router({
rateLimit: {
max: 100,
window: 60000,
keyGenerator: (req) => req.user?.id || req.event.requestContext?.identity?.sourceIp,
},
});interface RouterOptions {
cors?: boolean | CorsOptions;
rateLimit?: RateLimitOptions;
logging?: boolean | LoggingOptions;
logger?: LoggerInterface; // Custom logger (Winston, Pino, etc.)
responseTransform?: (data: any, req: RouterRequest) => any;
apiKey?: ApiKeyOptions;
}interface RouterRequest {
event: APIGatewayProxyEvent; // Original Lambda event
context?: Context; // Lambda context
params: Record<string, string>; // Path parameters
query: Record<string, string>; // Query parameters
headers: Record<string, string>; // Normalized headers
body: any; // Parsed body
path: string; // Request path
method: string; // HTTP method
user?: any; // Set by auth middleware
meta: Record<string, any>; // Custom metadata
}HttpError(statusCode, message, details?) // Base class
ValidationError(message, details?) // 400
UnauthorizedError(message?) // 401
ForbiddenError(message?) // 403
NotFoundError(message?) // 404
ConflictError(message?) // 409
TooManyRequestsError(message?, retryAfter?) // 429
InternalServerError(message?) // 500See the examples/ directory:
basic.ts- Simple routing and validationadvanced.ts- Full-featured application with auth, validation, CORS, rate limiting
Contributions are welcome! Please feel free to submit a Pull Request.
MIT © Utsab Pant
If you find this package helpful, please star it on GitHub!
For issues or questions, please file an issue on the GitHub repository.