Serverless architecture has matured significantly in 2026. With improved cold start times, better developer experience, and cost-effective scaling, serverless is now suitable for everything from MVPs to enterprise applications. Here's how to build production-ready serverless APIs.
Why Serverless?
Serverless computing offers compelling advantages:
- Zero server management - Focus on code, not infrastructure
- Automatic scaling - Handle 10 or 10 million requests
- Pay-per-use - Only pay for actual execution time
- Built-in redundancy - High availability by default
Serverless Platforms Compared
Vercel Functions
Best for Next.js applications and edge computing.
// app/api/hello/route.ts export async function GET(request: Request) { const { searchParams } = new URL(request.url) const name = searchParams.get('name') || 'World' return Response.json({ message: `Hello, ${name}!`, timestamp: new Date().toISOString() }) } export const runtime = 'edge' // or 'nodejs' export const maxDuration = 60 // seconds
AWS Lambda
Enterprise-grade with extensive AWS ecosystem integration.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda' export const handler = async ( event: APIGatewayProxyEvent ): Promise<APIGatewayProxyResult> => { const name = event.queryStringParameters?.name || 'World' return { statusCode: 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, body: JSON.stringify({ message: `Hello, ${name}!`, timestamp: new Date().toISOString(), }), } }
Architecture Patterns
Pattern 1: API Gateway + Lambda
Classic serverless pattern for REST APIs.
// Lambda function with TypeScript import { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb' const client = new DynamoDBClient({}) const docClient = DynamoDBDocumentClient.from(client) interface User { id: string name: string email: string } export const handler = async (event: any) => { const { httpMethod, pathParameters, body } = event try { switch (httpMethod) { case 'GET': return await getUser(pathParameters.id) case 'POST': return await createUser(JSON.parse(body)) default: return response(405, { error: 'Method not allowed' }) } } catch (error) { console.error('Error:', error) return response(500, { error: 'Internal server error' }) } } async function getUser(id: string) { const result = await docClient.send( new GetCommand({ TableName: process.env.USERS_TABLE!, Key: { id }, }) ) if (!result.Item) { return response(404, { error: 'User not found' }) } return response(200, result.Item) } async function createUser(user: User) { await docClient.send( new PutCommand({ TableName: process.env.USERS_TABLE!, Item: { ...user, createdAt: new Date().toISOString(), }, }) ) return response(201, user) } function response(statusCode: number, body: any) { return { statusCode, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, body: JSON.stringify(body), } }
Pattern 2: Event-Driven Architecture
Use Lambda with EventBridge for loosely coupled systems.
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge' const eventBridge = new EventBridgeClient({}) // Producer: Emit events export const orderCreatedHandler = async (event: any) => { const order = JSON.parse(event.body) // Save order to database await saveOrder(order) // Emit event await eventBridge.send( new PutEventsCommand({ Entries: [ { Source: 'orders', DetailType: 'OrderCreated', Detail: JSON.stringify(order), EventBusName: 'default', }, ], }) ) return response(201, order) } // Consumer: Process notifications export const sendOrderNotificationHandler = async (event: any) => { const order = JSON.parse(event.detail) await sendEmail({ to: order.customerEmail, subject: 'Order Confirmation', body: `Your order ${order.id} has been confirmed!`, }) return { statusCode: 200 } } // Consumer: Update inventory export const updateInventoryHandler = async (event: any) => { const order = JSON.parse(event.detail) for (const item of order.items) { await decrementInventory(item.productId, item.quantity) } return { statusCode: 200 } }
Pattern 3: Edge Functions for Global Performance
Deploy functions at the edge for minimal latency.
// app/api/geo/route.ts export const runtime = 'edge' export async function GET(request: Request) { // Geolocation from Vercel Edge const country = request.headers.get('x-vercel-ip-country') const city = request.headers.get('x-vercel-ip-city') // Personalized response based on location const currency = getCurrencyForCountry(country) const language = getLanguageForCountry(country) return Response.json({ location: { country, city }, preferences: { currency, language }, }) } function getCurrencyForCountry(country: string | null): string { const currencies: Record<string, string> = { US: 'USD', GB: 'GBP', EU: 'EUR', JP: 'JPY', } return currencies[country || ''] || 'USD' }
Pattern 4: Queue-Based Processing
Handle background jobs with SQS and Lambda.
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs' import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3' const sqs = new SQSClient({}) const s3 = new S3Client({}) // Producer: Queue image processing export const uploadImageHandler = async (event: any) => { const { bucket, key } = event.Records[0].s3 await sqs.send( new SendMessageCommand({ QueueUrl: process.env.IMAGE_QUEUE_URL!, MessageBody: JSON.stringify({ bucket, key }), }) ) return { statusCode: 200 } } // Consumer: Process images export const processImageHandler = async (event: any) => { for (const record of event.Records) { const { bucket, key } = JSON.parse(record.body) // Get image from S3 const response = await s3.send( new GetObjectCommand({ Bucket: bucket, Key: key }) ) const imageBuffer = await streamToBuffer(response.Body) // Process image (resize, optimize, etc.) const processed = await processImage(imageBuffer) // Upload processed image await uploadProcessedImage(processed, bucket, key) } return { statusCode: 200 } }
Best Practices
1. Connection Reuse
import { DynamoDBClient } from '@aws-sdk/client-dynamodb' // Initialize outside handler for connection reuse const client = new DynamoDBClient({ maxAttempts: 3, }) export const handler = async (event: any) => { // Use client here // Connection is reused across invocations }
2. Environment Variables & Secrets
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager' const secretsManager = new SecretsManagerClient({}) // Cache secrets for the lifetime of the Lambda container let cachedSecret: string | null = null async function getSecret(): Promise<string> { if (cachedSecret) { return cachedSecret } const response = await secretsManager.send( new GetSecretValueCommand({ SecretId: process.env.SECRET_ID!, }) ) cachedSecret = response.SecretString! return cachedSecret } export const handler = async (event: any) => { const apiKey = await getSecret() // Use API key }
3. Error Handling & Retries
export const handler = async (event: any) => { try { await processEvent(event) return { statusCode: 200 } } catch (error) { console.error('Processing failed:', error) // Throw error to trigger SQS retry if (isRetryable(error)) { throw error } // Send to DLQ for manual review await sendToDeadLetterQueue(event, error) return { statusCode: 200 } // Acknowledge to prevent retry } } function isRetryable(error: any): boolean { // Retry on transient errors return ( error.code === 'ServiceUnavailable' || error.code === 'ThrottlingException' ) }
4. Observability
import { captureAWSv3Client } from 'aws-xray-sdk-core' import { DynamoDBClient } from '@aws-sdk/client-dynamodb' // Instrument AWS SDK clients const client = captureAWSv3Client(new DynamoDBClient({})) export const handler = async (event: any) => { // Custom metrics console.log(JSON.stringify({ metric: 'OrderProcessed', value: 1, unit: 'Count', timestamp: Date.now(), })) // Structured logging console.log(JSON.stringify({ level: 'INFO', message: 'Processing order', orderId: event.orderId, userId: event.userId, })) await processOrder(event) return { statusCode: 200 } }
Infrastructure as Code
service: my-api provider: name: aws runtime: nodejs20.x stage: ${opt:stage, 'dev'} region: us-east-1 environment: USERS_TABLE: ${self:service}-users-${self:provider.stage} iam: role: statements: - Effect: Allow Action: - dynamodb:GetItem - dynamodb:PutItem - dynamodb:Query Resource: arn:aws:dynamodb:*:*:table/${self:provider.environment.USERS_TABLE} functions: getUser: handler: src/handlers/users.getUser events: - http: path: users/{id} method: get cors: true createUser: handler: src/handlers/users.createUser events: - http: path: users method: post cors: true resources: Resources: UsersTable: Type: AWS::DynamoDB::Table Properties: TableName: ${self:provider.environment.USERS_TABLE} BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: id AttributeType: S KeySchema: - AttributeName: id KeyType: HASH
Cost Optimization
- Right-size memory allocation - More memory = faster execution but higher cost
- Use provisioned concurrency sparingly - Eliminates cold starts but adds cost
- Implement caching - Reduce function invocations
- Use reserved capacity - For predictable workloads
- Monitor and alert - Set up budget alerts
Common Pitfalls
Cold starts:
- Keep functions lightweight
- Use provisioned concurrency for critical paths
- Consider edge functions for fast global response
Timeout configuration:
- Set appropriate timeouts for each function
- Implement graceful shutdown
- Use async processing for long-running tasks
Concurrent execution limits:
- Monitor and request increases
- Implement queue-based throttling
- Use SQS for burst protection
Conclusion
Serverless architecture enables rapid development and automatic scaling without infrastructure management. Use Vercel Functions for Next.js applications and edge computing, AWS Lambda for complex enterprise workflows, and event-driven patterns for decoupled systems. Follow best practices for connection reuse, error handling, and observability to build reliable, cost-effective serverless applications that scale effortlessly.