Back to blog

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.

Serverless Architecture

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.json

Robust 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.

Let us go! 🦅

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments