DEV Community

Cover image for Building a Serverless URL Shortener: A Practical AWS Project

Building a Serverless URL Shortener: A Practical AWS Project

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
  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

Top comments (0)