Serverless Architecture with JavaScript: Complete Guide with AWS Lambda and Vercel
Hello HaWkers, serverless has gone from being a buzzword to becoming one of the most adopted architectures in production. In 2025, understanding serverless is no longer optional - it is an essential skill for developers who want to build scalable applications without managing infrastructure.
Have you ever thought about not worrying about servers, provisioning, or scalability? Paying only for what you use? This is the promise of serverless, and in this article we will explore how to implement it in practice.
What is Serverless and Why It Matters
Serverless does not mean "without server" - it means you do not need to manage servers. The infrastructure is fully abstracted, allowing you to focus only on code.

Advantages of the Serverless Model
Cost:
- Pay only for execution time
- No idle server costs
- Automatic scaling without provisioning extra resources
Operational:
- Zero infrastructure management
- Simplified deployment
- Automatic high availability
Development:
- Total focus on code
- Fast iteration
- Shorter time-to-market
AWS Lambda: The Industry Standard
AWS Lambda is the most used serverless service in the world. Let us see how to create robust functions with Node.js.
Basic Lambda Structure
// handler.ts
import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
interface UserInput {
name: string;
email: string;
age?: number;
}
interface StandardResponse {
success: boolean;
message: string;
data?: unknown;
}
// Utility function for standardized responses
const createResponse = (
statusCode: number,
body: StandardResponse
): APIGatewayProxyResult => ({
statusCode,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true
},
body: JSON.stringify(body)
});
export const createUser: APIGatewayProxyHandler = async (event) => {
try {
// Validate request body
if (!event.body) {
return createResponse(400, {
success: false,
message: 'Request body is required'
});
}
const data: UserInput = JSON.parse(event.body);
// Business validations
if (!data.name || !data.email) {
return createResponse(400, {
success: false,
message: 'Name and email are required'
});
}
if (!data.email.includes('@')) {
return createResponse(400, {
success: false,
message: 'Invalid email'
});
}
// Simulate database creation (in production, use DynamoDB or other)
const newUser = {
id: Date.now().toString(),
...data,
createdAt: new Date().toISOString()
};
// Log to CloudWatch
console.log('User created:', JSON.stringify(newUser));
return createResponse(201, {
success: true,
message: 'User created successfully',
data: newUser
});
} catch (error) {
console.error('Error creating user:', error);
return createResponse(500, {
success: false,
message: 'Internal server error'
});
}
};Configuration with Serverless Framework
# serverless.yml
service: users-api
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
stage: ${opt:stage, 'dev'}
memorySize: 256
timeout: 10
environment:
STAGE: ${self:provider.stage}
TABLE_NAME: ${self:service}-${self:provider.stage}-users
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Query
- dynamodb:Scan
Resource:
- !GetAtt UsersTable.Arn
functions:
createUser:
handler: src/handlers/users.createUser
events:
- http:
path: users
method: post
cors: true
getUser:
handler: src/handlers/users.getUser
events:
- http:
path: users/{id}
method: get
cors: true
listUsers:
handler: src/handlers/users.listUsers
events:
- http:
path: users
method: get
cors: true
resources:
Resources:
UsersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.TABLE_NAME}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
plugins:
- serverless-esbuild
- serverless-offline
custom:
esbuild:
bundle: true
minify: true
sourcemap: true
target: node20
Integration with DynamoDB
// src/lib/dynamodb.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
QueryCommand,
ScanCommand
} from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({
region: process.env.AWS_REGION || 'us-east-1'
});
export const dynamoDB = DynamoDBDocumentClient.from(client);
// src/handlers/users.ts
import { APIGatewayProxyHandler } from 'aws-lambda';
import { dynamoDB } from '../lib/dynamodb';
import { GetCommand, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';
const TABLE_NAME = process.env.TABLE_NAME!;
export const createUser: APIGatewayProxyHandler = async (event) => {
try {
const data = JSON.parse(event.body || '{}');
const user = {
id: uuidv4(),
name: data.name,
email: data.email,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await dynamoDB.send(new PutCommand({
TableName: TABLE_NAME,
Item: user,
ConditionExpression: 'attribute_not_exists(id)'
}));
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, data: user })
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ success: false, error: 'Internal error' })
};
}
};
export const getUser: APIGatewayProxyHandler = async (event) => {
try {
const { id } = event.pathParameters || {};
if (!id) {
return {
statusCode: 400,
body: JSON.stringify({ success: false, message: 'ID required' })
};
}
const result = await dynamoDB.send(new GetCommand({
TableName: TABLE_NAME,
Key: { id }
}));
if (!result.Item) {
return {
statusCode: 404,
body: JSON.stringify({ success: false, message: 'User not found' })
};
}
return {
statusCode: 200,
body: JSON.stringify({ success: true, data: result.Item })
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ success: false, error: 'Internal error' })
};
}
};
export const listUsers: APIGatewayProxyHandler = async () => {
try {
const result = await dynamoDB.send(new ScanCommand({
TableName: TABLE_NAME,
Limit: 100
}));
return {
statusCode: 200,
body: JSON.stringify({
success: true,
data: result.Items || [],
total: result.Count
})
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
body: JSON.stringify({ success: false, error: 'Internal error' })
};
}
};
Vercel Edge Functions: Performance at the Edge
Vercel offers a serverless experience focused on performance, running functions at the edge (closer to the user).
API Routes with Next.js
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
// Simulating database
const products: Product[] = [
{ id: '1', name: 'Pro Laptop', price: 4500, stock: 10 },
{ id: '2', name: 'Gaming Mouse', price: 250, stock: 50 },
{ id: '3', name: 'Mechanical Keyboard', price: 450, stock: 30 }
];
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const limit = parseInt(searchParams.get('limit') || '10');
let result = [...products];
if (category) {
// Filter by category if needed
}
result = result.slice(0, limit);
return NextResponse.json({
success: true,
data: result,
total: result.length
});
}
export async function POST(request: NextRequest) {
try {
const data = await request.json();
// Validations
if (!data.name || !data.price) {
return NextResponse.json(
{ success: false, error: 'Name and price are required' },
{ status: 400 }
);
}
const newProduct: Product = {
id: Date.now().toString(),
name: data.name,
price: data.price,
stock: data.stock || 0
};
products.push(newProduct);
return NextResponse.json(
{ success: true, data: newProduct },
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Error processing request' },
{ status: 500 }
);
}
}Edge Functions for Personalization
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*']
};
export function middleware(request: NextRequest) {
// Detect user country via header
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Add localization headers
const response = NextResponse.next();
response.headers.set('x-user-country', country);
response.headers.set('x-user-city', city);
// Simple rate limiting based on IP
const ip = request.ip || 'unknown';
const rateLimitKey = `rate-limit:${ip}`;
// In production, use a service like Upstash Redis
// for distributed rate limiting
// Log request
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} - ${country}/${city}`);
return response;
}
// app/api/personalization/route.ts
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge'; // Run at the edge
export async function GET(request: NextRequest) {
const country = request.headers.get('x-user-country') || 'US';
// Location-based personalization
const settings = {
US: {
currency: 'USD',
language: 'en-US',
dateFormat: 'MM/dd/yyyy',
offers: ['free-shipping', 'black-friday']
},
BR: {
currency: 'BRL',
language: 'pt-BR',
dateFormat: 'dd/MM/yyyy',
offers: ['free-shipping-southeast', 'pix-discount']
}
};
const config = settings[country as keyof typeof settings] || settings.US;
return NextResponse.json({
success: true,
data: config,
processedAt: 'edge',
region: request.geo?.region || 'unknown'
});
}
Patterns and Best Practices
Cold Start: Minimizing Latency
Cold start is the time it takes for a function to initialize on first execution. Here are strategies to minimize it:
// AVOID: dynamic imports inside the handler
export const handler = async () => {
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb'); // Cold start!
// ...
};
// PREFER: imports at the top of the file
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Initialize connections outside the handler (reused between invocations)
const client = new DynamoDBClient({});
export const handler = async () => {
// client is already initialized
};Recommended Project Structure
serverless-project/
├── src/
│ ├── handlers/ # Lambda functions
│ │ ├── users.ts
│ │ ├── products.ts
│ │ └── orders.ts
│ ├── lib/ # Shared utilities
│ │ ├── dynamodb.ts
│ │ ├── s3.ts
│ │ └── validation.ts
│ ├── types/ # TypeScript types
│ │ └── index.ts
│ └── middlewares/ # Custom middlewares
│ └── authentication.ts
├── tests/
│ ├── unit/
│ └── integration/
├── serverless.yml
├── package.json
└── tsconfig.jsonRobust Error Handling
// src/lib/errors.ts
export class ApplicationError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = 'ApplicationError';
}
}
export class ValidationError extends ApplicationError {
constructor(message: string) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class NotFoundError extends ApplicationError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class UnauthorizedError extends ApplicationError {
constructor() {
super('Unauthorized', 401, 'UNAUTHORIZED');
}
}
// src/lib/handler-wrapper.ts
import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import { ApplicationError } from './errors';
type HandlerFn = (event: any, context: any) => Promise<any>;
export const wrapHandler = (fn: HandlerFn): APIGatewayProxyHandler => {
return async (event, context): Promise<APIGatewayProxyResult> => {
try {
const result = await fn(event, context);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ success: true, data: result })
};
} catch (error) {
console.error('Handler error:', error);
if (error instanceof ApplicationError) {
return {
statusCode: error.statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: error.message,
code: error.code
})
};
}
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: 'Internal server error'
})
};
}
};
};
// Usage:
import { wrapHandler } from '../lib/handler-wrapper';
import { ValidationError, NotFoundError } from '../lib/errors';
export const getProduct = wrapHandler(async (event) => {
const { id } = event.pathParameters;
if (!id) {
throw new ValidationError('Product ID is required');
}
const product = await fetchProductFromDatabase(id);
if (!product) {
throw new NotFoundError('Product');
}
return product;
});
When to Use (and When to Avoid) Serverless
Ideal Use Cases
Use serverless when:
- Unpredictable or spiky workloads
- REST/GraphQL APIs
- Event processing (webhooks, queues)
- Automations and scheduled tasks
- Rapid prototyping
- Projects with limited budget
When to Consider Alternatives
Avoid serverless when:
- Long-duration processing (>15 minutes)
- Applications requiring ultra-low consistent latency
- Workloads with high constant utilization (can be more expensive)
- Need for persistent in-memory state
Cost Comparison
Scenario: API with 1 million requests/month
| Provider | Estimated Cost |
|---|---|
| AWS Lambda | ~$0.20 (128MB, 100ms) |
| Vercel Pro | Included in $20/month plan |
| Google Cloud Functions | ~$0.40 |
| Azure Functions | ~$0.20 |
Actual costs vary with memory, execution time, and data transfer.
Monitoring and Observability
For production, it is essential to have visibility into your functions:
Recommended tools:
- AWS CloudWatch - Native logs and metrics
- Datadog - Complete APM for serverless
- Lumigo - Visual debugging for serverless
- Sentry - Error tracking
If you want to dive deeper into web application performance, I recommend checking out the article Modern Browser Web APIs which complements serverless concepts well.

