Building a Serverless URL Shortener: A Practical AWS Project



This content originally appeared on DEV Community and was authored by Keyur Modi

A Step-by-Step Guide to Building a Production-Ready Service Using AWS Lambda, DynamoDB, and API Gateway

🎯 What We’re Building

Ever wondered how URL shorteners work? In this tutorial, we’ll build one from scratch using AWS serverless services. The best part? It’ll run completely within AWS’s free tier limits.

Key Features

  • Serverless architecture

  • Production-ready code

  • Scalable design

  • Cost-effective implementation

  • Complete infrastructure as code

Technologies We’ll Use

  • AWS Lambda

  • Amazon DynamoDB

  • Amazon API Gateway

  • Node.js

  • Serverless Framework

🚀 Getting Started

Prerequisites

Before we dive in, make sure you have:

✓ AWS Account (free tier eligible)
✓ Node.js 18.x or later
✓ AWS CLI installed & configured
✓ Serverless Framework
✓ Your favorite code editor

🏗 Project Architecture

Project Architecture Diagram

Let’s break down what’s happening in our architecture:

  1. Client Layer sends requests to:
  • Create short URLs (POST /url)

  • Access shortened URLs (GET /{shortId})

  1. API Gateway handles:
  • Request routing

  • Method validation

  • Traffic management

  1. Lambda Functions manage:
  • URL creation

  • Redirection logic

  • Error handling

  1. DynamoDB stores:
  • URL mappings

  • Creation timestamps

  • Optional expiry dates

💻 Implementation

Step 1: Project Setup

mkdir url-shortener
cd url-shortener
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb nanoid
npm install --save-dev serverless-offline

Step 2: Infrastructure Definition

Let’s create our serverless.yml configuration file:

service: url-shortener

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  environment:
    DYNAMODB_TABLE: ${self:service}-${sls:stage}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:Query
            - dynamodb:Scan
            - dynamodb:GetItem
            - dynamodb:PutItem
          Resource: "arn:aws:dynamodb:${aws:region}:*:table/${self:provider.environment.DYNAMODB_TABLE}"

functions:
  createShortUrl:
    handler: handlers/create.handler
    events:
      - http:
          path: url
          method: post
          cors: true

  redirectToLongUrl:
    handler: handlers/redirect.handler
    events:
      - http:
          path: /{shortId}
          method: get
          cors: true

resources:
  Resources:
    UrlsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.DYNAMODB_TABLE}
        AttributeDefinitions:
          - AttributeName: shortId
            AttributeType: S
        KeySchema:
          - AttributeName: shortId
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

Step 3: Core Functions Implementation

  1. URL Creation Handler (handlers/create.js)

    const { nanoid } = require(‘nanoid’);
    const { saveUrl } = require(‘../utils/dynamodb’);

    module.exports.handler = async (event) => {
    try {
    // Parse request body
    const { url, customId } = JSON.parse(event.body);

    // Validate input
    if (!url) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: 'URL is required' }),
      };
    }
    
    // Validate URL format
    try {
      new URL(url);
    } catch (error) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: 'Invalid URL format' }),
      };
    }
    
    // Generate or use custom short ID
    const shortId = customId || nanoid(8);
    
    // Save URL mapping
    const urlMapping = await saveUrl(shortId, url);
    
    // Return success response
    return {
      statusCode: 201,
      body: JSON.stringify({
        shortId,
        shortUrl: `${event.requestContext.domainName}/${shortId}`,
        originalUrl: url,
        createdAt: urlMapping.createdAt,
      }),
    };
    

    } catch (error) {
    console.error(‘Error creating short URL:’, error);
    return {
    statusCode: 500,
    body: JSON.stringify({ error: ‘Could not create short URL’ }),
    };
    }
    };

    1. URL Redirect Handler (handlers/redirect.js)

    const { getUrl } = require(‘../utils/dynamodb’);

    module.exports.handler = async (event) => {
    try {
    // Get shortId from path parameters
    const { shortId } = event.pathParameters;

    // Lookup URL mapping
    const urlMapping = await getUrl(shortId);
    
    // Handle not found
    if (!urlMapping) {
      return {
        statusCode: 404,
        body: JSON.stringify({ error: 'Short URL not found' }),
      };
    }
    
    // Check for URL expiration
    if (urlMapping.expiresAt && new Date(urlMapping.expiresAt) < new Date()) {
      return {
        statusCode: 410,
        body: JSON.stringify({ error: 'Short URL has expired' }),
      };
    }
    
    // Return redirect
    return {
      statusCode: 301,
      headers: {
        Location: urlMapping.originalUrl,
      },
    };
    

    } catch (error) {
    console.error(‘Error redirecting URL:’, error);
    return {
    statusCode: 500,
    body: JSON.stringify({ error: ‘Could not redirect to URL’ }),
    };
    }
    };

    1. DynamoDB Utility (utils/dynamodb.js)

    const { DynamoDBClient } = require(‘@aws-sdk/client-dynamodb’);
    const { DynamoDBDocumentClient, PutCommand, GetCommand } = require(‘@aws-sdk/lib-dynamodb’);

    const client = new DynamoDBClient({});
    const ddbDocClient = DynamoDBDocumentClient.from(client);

    const TableName = process.env.DYNAMODB_TABLE;

    async function saveUrl(shortId, originalUrl, expiresAt = null) {
    const params = {
    TableName,
    Item: {
    shortId,
    originalUrl,
    createdAt: new Date().toISOString(),
    expiresAt: expiresAt?.toISOString() || null,
    },
    };

    await ddbDocClient.send(new PutCommand(params));
    return params.Item;
    }

    async function getUrl(shortId) {
    const params = {
    TableName,
    Key: { shortId },
    };

    const { Item } = await ddbDocClient.send(new GetCommand(params));
    return Item;
    }

    module.exports = {
    saveUrl,
    getUrl,
    };

🚀 Deployment & Testing

Deployment

# Deploy the service
serverless deploy

# The output will show your API endpoints:
# POST - https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/url
# GET - https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/{shortId}

Testing Your Service

  1. Create a short URL:

    curl -X POST \
    https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/url \
    -H ‘Content-Type: application/json’ \
    -d ‘{“url”: “https://example.com/very/long/url”}

Expected response:

{
  "shortId": "Km2i8_js",
  "shortUrl": "https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/Km2i8_js",
  "originalUrl": "https://example.com/very/long/url",
  "createdAt": "2024-11-18T10:30:00.000Z"
}
  1. Use the short URL:

📈 Monitoring & Operations

CloudWatch Integration

Your Lambda functions automatically log to CloudWatch. Access logs at:

  • /aws/lambda/url-shortener-dev-createShortUrl

  • /aws/lambda/url-shortener-dev-redirectToLongUrl

Cost Management

Free Tier Limits:

  • 1M Lambda requests/month

  • 1M API Gateway requests/month

  • 25GB DynamoDB storage

🔒 Security Best Practices

  1. IAM Roles
  • Least privilege principle

  • Function-specific permissions

  • Regular audit

  1. API Security
  • Input validation

  • Rate limiting

  • CORS configuration

  1. Data Security
  • DynamoDB encryption at rest

  • HTTPS endpoints

  • No sensitive data storage

🚀 Going Further

Potential Enhancements

  1. Custom Domains

    Add to serverless.yml

    custom:
    customDomain:
    domainName: short.yourdomain.com
    certificateName: ‘*.yourdomain.com’
    createRoute53Record: true

    1. Analytics
  • Add view counting

  • Geographic tracking

  • Usage patterns

  1. User Management
  • AWS Cognito integration

  • User-specific URLs

  • Access control

  1. Advanced Features
  • URL expiration

  • Custom short URLs

  • QR code generation

💡 Pro Tips

  1. Performance Optimization
  • Use AWS SDK v3

  • Implement caching

  • Optimize Lambda cold starts

  1. Cost Optimization
  • Monitor usage

  • Set up alerts

  • Use provisioned capacity when needed

🎯 Common Pitfalls and Solutions

  1. Deployment Issues
  • Double-check AWS credentials

  • Verify IAM permissions

  • Check service names

  1. Performance Issues
  • Monitor cold starts

  • Implement warm-up

  • Use proper indexing

🔚 Conclusion

You now have a production-ready URL shortener service that’s:

  • Scalable to millions of requests

  • Cost-effective (free tier eligible)

  • Maintainable and extensible

  • Secured with proper IAM roles

The project demonstrates key AWS serverless concepts while providing a practical, useful service.

🔗 Resources


This content originally appeared on DEV Community and was authored by Keyur Modi