Design and Build a URL Shortener on AWS

Build a serverless URL shortener with Lambda, DynamoDB, and API Gateway.

Introduction

30 Second Summary

Every time you share a link on social media, there's a good chance a URL shortener turned a long, messy address into something clean and clickable. Behind that simple redirect is a real system design challenge that comes up constantly in technical interviews.

In this project, you will build and deploy a serverless URL shortener on AWS. You'll implement the Base62 encoding algorithm from scratch, use DynamoDB atomic counters for collision-free IDs, and define all infrastructure as code using AWS SAM.

What You'll Build

A serverless URL shortener deployed on AWS that accepts long URLs via an API, generates short codes using Base62 encoding, and redirects visitors using HTTP 301 responses.

By the end of this project, you'll have:

  • A working Base62 encoding utility that converts numeric IDs into short, URL-safe codes.
  • Two Lambda functions with separate read and write responsibilities, backed by a DynamoDB table with atomic counter-based ID generation.
  • A SAM template defining your entire infrastructure as code, deployable to AWS in one command.
  • Secret Mission: Add click analytics tracking and URL expiration with DynamoDB TTL.

Are there any prerequisites?

You'll need an AWS account (free tier is all you need). All other tools are installed during Step 1.

Before We Start

Before We Start...

Install AWS CLI and Configure Credentials

Before you can deploy anything to AWS, your local machine needs a way to talk to AWS services. The AWS CLI (Command Line Interface) is the tool that makes this possible.

In this step, you will install the AWS CLI, create an IAM user with programmatic access, and configure your credentials so that every future AWS command knows who you are.

In this step, get ready to:
  • Install the AWS CLI v2 on Windows.
  • Create an IAM user with programmatic access in the AWS Console.
  • Configure the AWS CLI with your access credentials.
Install AWS CLI v2 on Windows

The AWS CLI lets you interact with AWS services from your terminal. You need version 2 for this project.

  • Download the AWS CLI v2 MSI installer by opening this link in your browser:
  • https://awscli.amazonaws.com/AWSCLIV2.msi
  • Run the downloaded AWSCLIV2.msi file and follow the installer prompts, accepting the defaults.
  • Once the installer finishes, close any open Command Prompt or PowerShell windows and open a new one.

Why open a new terminal?

The installer adds the aws command to your system PATH. Existing terminal windows still use the old PATH, so they won't find the new command until you reopen them.

  • Verify the installation by running this command:
aws --version

✔️ I see aws-cli/2.x.x

Your output looks something like aws-cli/2.x.x Python/3.x.x Windows/10 exe/AMD64. The exact version numbers will differ, but as long as you see aws-cli/2 at the start, you are good to go.

ⓧ I see aws-cli/1.x.x

You have an older version of the AWS CLI installed. The v2 MSI installer should replace it, but if the old version still appears, your PATH may point to the v1 binary first.

  • Open the Windows Start menu and search for Add or remove programs.
  • Find and uninstall the older AWS CLI entry.
  • Re-run the v2 MSI installer from the link above.
  • Open a fresh Command Prompt and run aws --version again.

ⓧ Command not recognized

If you see a message like "is not recognized as an internal or external command," the installer may not have finished or the PATH was not updated.

  • Make sure you closed and reopened your terminal after running the installer.
  • If the issue persists, re-run the AWSCLIV2.msi installer. Choose Repair if prompted.
  • Open a new Command Prompt and try aws --version once more.
Create an IAM user with programmatic access

Your AWS account's root user has full permissions, but you should never use root credentials for CLI access. Instead, you will create a dedicated IAM user whose access keys the CLI will use.

  • Open the IAM console in your browser:
  • https://console.aws.amazon.com/iam/
  • In the left navigation pane, click Users.
  • Click Create user.
  • Enter a user name such as url-shortener-cli.
  • Click Next.
  • On the permissions page, select Attach policies directly.
  • In the search box, type AdministratorAccess and check the box next to the AdministratorAccess policy.
  • Click Next, then click Create user.

Why AdministratorAccess?

For a learning project, AdministratorAccess keeps things simple. It grants the CLI permission to create Lambda functions, DynamoDB tables, and API Gateway routes without hitting permission errors.

In a production environment, you would create a narrow policy granting only the specific permissions your application needs.

Now you need to create an access key for this user.

  • Click on the user name you just created (url-shortener-cli) to open their details page.
  • Select the Security credentials tab.
  • Scroll down to the Access keys section and click Create access key.
  • For the use case, select Command Line Interface (CLI).
  • Check the confirmation checkbox at the bottom, then click Next.
  • Click Create access key.
  • Copy both the Access key ID and the Secret access key to a safe place (or click Download .csv file).
  • Click Done.

Save your secret access key now

AWS only shows the secret access key once at the time of creation. If you close this page without saving it, you will have to delete the key and create a new one.

Run aws configure to connect your credentials

With the access key in hand, you can now tell the AWS CLI who you are. The aws configure command stores your credentials locally so every future command authenticates automatically.

  • In your Command Prompt, run:
aws configure

What will this command ask?

The aws configure command will prompt you for four pieces of information, one at a time. Here is how to respond:

  • AWS Access Key ID - paste the Access Key ID you saved.
  • AWS Secret Access Key - paste the Secret Access Key you saved.
  • Default region name - enter us-east-1 (or another region close to you).
  • Default output format - enter json.
  • After completing all four prompts, verify your credentials are working by running:
aws sts get-caller-identity

You should see a JSON response containing your account ID, user ID, and ARN. This confirms your CLI can authenticate with AWS.

Seeing an error about credentials?

If you see a message about being unable to locate credentials or an access denied error, double check that you pasted the access key ID and secret key correctly. You can re-run aws configure at any time to overwrite the stored values.

help me fix an AWS CLI authentication error after running aws configure

Your local machine is now authenticated with AWS. Next up, you will install Node.js, SAM CLI, and scaffold the project folder structure.

Install Node.js, SAM CLI, and Scaffold the Project

Your AWS credentials are configured and ready. Now you need the tools that will actually build and deploy your Lambda functions.

This project uses Node.js 20.x because that matches the nodejs20.x Lambda runtime on AWS. You also need the AWS SAM CLI to define your infrastructure as code and deploy everything in one command.

In this step, get ready to:
  • Install Node.js 20.x and verify it works.
  • Install AWS SAM CLI and enable Windows long paths.
  • Create the project directory structure with all required files.
Install Node.js 20.x LTS

AWS Lambda runs your functions using a specific Node.js version. This project targets Node.js 20.x to match the nodejs20.x runtime you will declare in your SAM template later.

  • Open your browser and navigate to the Node.js 20 download page:
  • https://nodejs.org/en/download/prebuilt-installer
  • Select version 20 from the version dropdown.
  • Download the Windows 64-bit .msi installer.
  • Run the installer and accept all default options.
  • Close and reopen your terminal after the install completes.

Check that Node.js is installed correctly by running this command:

node --version

✔️ I see version 20 or higher

You should see something like v20.20.2 in your terminal. Node.js is ready to go.

ⓧ I see a version lower than 20

You have an older version of Node.js installed. You need version 20.x specifically for this project.

  • Go to https://nodejs.org/en/download/prebuilt-installer and select version 20 from the dropdown.
  • Download and run the Windows 64-bit .msi installer.
  • The new installer will replace your existing Node.js version.
  • Close and reopen your terminal, then run node --version again to confirm you see v20.x.x.

ⓧ Command not found

Node.js is not installed yet. Follow these steps to install it:

  • Go to https://nodejs.org/en/download/prebuilt-installer and select version 20 from the dropdown.
  • Download the Windows 64-bit .msi installer.
  • Run the installer and accept all default options.
  • Close and reopen your terminal, then run node --version again to confirm you see v20.x.x.
Install AWS SAM CLI and enable long paths

The AWS SAM CLI is the tool that packages your Lambda code and deploys your entire stack to AWS. On Windows, SAM requires long file path support to be enabled.

  • Enable Windows long paths by running this command in an elevated PowerShell terminal (right-click PowerShell and choose Run as administrator):
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

Why enable long paths?

Windows has a default 260-character limit on file paths. SAM CLI creates deeply nested temporary folders during builds, which can exceed this limit and cause errors. Enabling long paths removes this restriction.

Verify SAM CLI is installed by running:

sam --version

✔️ I see a version number

SAM CLI is installed and ready. You should see output showing the SAM CLI version.

ⓧ Command not found

The SAM CLI installer may not have added itself to your PATH correctly.

  • Make sure you completely closed and reopened your terminal after installing.
  • If it still does not work, re-download and run the MSI installer from the AWS SAM CLI docs page linked above.
  • Restart your computer if the issue persists.

Having trouble with either install?

Make sure you are closing your terminal completely and opening a fresh one after each install. Terminals only pick up PATH changes on startup.

help me troubleshoot Node.js or SAM CLI installation on Windows

Create the project directory structure

Your URL shortener needs a specific folder layout. Each Lambda function lives in its own directory, and a template.yaml file at the root defines all your infrastructure.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
  • Create the subdirectories for your two Lambda functions by running:
mkdir shorten
mkdir redirect
  • Create the empty files your project needs by running:
echo. > template.yaml
echo. > shorten\index.mjs
echo. > redirect\index.mjs
  • Confirm the files exist by running:
dir /s /b

You should see template.yaml at the root, shorten\index.mjs, and redirect\index.mjs listed in the output.

What is this folder structure?

Each Lambda function gets its own folder (shorten/ and redirect/). SAM uses this layout to package each function independently. The template.yaml at the root is your Infrastructure as Code file that tells AWS what resources to create.

Don't see all your files?

Make sure you ran the cd url-shortener command before creating subdirectories. If you are in the wrong folder, delete it and start from the cd ~/Desktop step again.

help me fix my project directory structure for the URL shortener

Your development environment is fully set up. Next up, you will write the Base62 encoding algorithm and build your first Lambda function.

Implement the Base62 Encoder and Shorten Handler

Your project structure is in place with empty function files ready to fill. Now it's time to write the core algorithm that powers every URL shortener.

Base62 encoding converts numeric IDs into short, URL-safe strings using 62 characters (digits, lowercase, uppercase). The challenge is generating those numeric IDs without collisions, even under concurrent traffic.

In this step, get ready to:
  • Write the Base62 encoding function that interviewers ask you to explain.
  • Implement collision-free ID generation using DynamoDB atomic counters.
  • Build the complete shorten handler that ties everything together.
Write the Base62 encoding function

Base62 encoding maps an integer to a compact string using 62 URL-safe characters. A 7-character Base62 code can represent approximately 3.5 trillion unique URLs.

  • Open shorten/index.mjs in VS Code and paste the following code:
// Import DynamoDB client and document-level helpers
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb";

// Create the low-level client and wrap with the document client
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
// SAM injects this environment variable from the template
const TABLE_NAME = process.env.TABLE_NAME;

// The 62-character alphabet: digits + lowercase + uppercase
const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

// Convert a numeric ID into a short Base62 string
function encodeBase62(num) {
  if (num === 0) return BASE62_CHARS[0];
  let result = "";
  while (num > 0) {
    // Take the remainder as the next character (built right to left)
    result = BASE62_CHARS[num % 62] + result;
    num = Math.floor(num / 62);
  }
  return result;
}

What does this code do?

  • The imports bring in AWS SDK v3 classes. DynamoDBDocumentClient simplifies reading and writing by automatically converting JavaScript objects to DynamoDB's attribute format.
  • TABLE_NAME reads from an environment variable that SAM injects at deploy time. This keeps the function decoupled from a hardcoded table name.
  • encodeBase62() repeatedly divides the input number by 62, using each remainder to pick a character from BASE62_CHARS. It builds the result string from right to left, just like converting decimal to binary but with base 62.

Why Base62 instead of hashing?

Hash-based approaches (like truncating MD5) can produce collisions, which means you need collision-detection logic and retry loops. Counter-based IDs combined with Base62 encoding are collision-free by construction. Each counter value maps to exactly one short code.

The trade-off is that codes are sequential and predictable. In production, you would XOR the counter with a secret to obscure the sequence. For this project, the simpler pattern is the right choice.

You won't see a change yet. The next chunk adds the counter function that feeds numbers into this encoder.

Implement the atomic counter with DynamoDB

To guarantee unique IDs without coordination, you need an atomic counter. DynamoDB's UpdateCommand with the ADD operation increments a value and returns the new number in a single atomic request.

  • Add the following function below the encodeBase62 function in shorten/index.mjs:
// Atomically increment a counter in DynamoDB and return the new value
async function getNextId() {
  const response = await docClient.send(new UpdateCommand({
    TableName: TABLE_NAME,
    // Use a dedicated row as the global counter
    Key: { pk: "COUNTER" },
    // ADD creates the attribute if missing, then increments
    UpdateExpression: "ADD counter_value :inc",
    ExpressionAttributeValues: { ":inc": 1 },
    // Return the updated value so we can use it immediately
    ReturnValues: "UPDATED_NEW",
  }));
  return response.Attributes.counter_value;
}

What does this code do?

  • getNextId() sends an UpdateCommand to a dedicated row with pk: "COUNTER". The ADD expression creates counter_value if it doesn't exist, then adds 1.
  • ReturnValues: "UPDATED_NEW" tells DynamoDB to hand back the new counter value in the same response. No second read required.
  • Because this is a single atomic operation, two concurrent requests will always get different counter values. No collision detection needed.

You won't see a change yet. The handler in the next chunk calls this function to generate each short code.

Build the complete shorten handler

The handler is the entry point that Lambda invokes when a POST request hits your /shorten endpoint. It ties together the counter and the encoder into a complete write path.

  • Add the handler function below getNextId in shorten/index.mjs:
export const handler = async (event) => {
  try {
    // Parse the incoming JSON body from the API request
    const body = JSON.parse(event.body);
    const longUrl = body.url;

    // Validate that a URL was provided
    if (!longUrl) {
      return {
        statusCode: 400,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Missing 'url' in request body" }),
      };
    }

    // Generate a unique short code using atomic counter + Base62
    const nextId = await getNextId();
    const shortCode = encodeBase62(nextId);

    // Store the URL mapping in DynamoDB
    await docClient.send(new PutCommand({
      TableName: TABLE_NAME,
      Item: {
        pk: shortCode,
        long_url: longUrl,
        created_at: new Date().toISOString(),
      },
    }));

    // Build the full short URL from the API Gateway domain
    const baseUrl = `https://${event.requestContext.domainName}`;

    return {
      statusCode: 201,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        short_url: `${baseUrl}/${shortCode}`,
        short_code: shortCode,
        long_url: longUrl,
      }),
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: "Internal server error" }),
    };
  }
};

What does this handler do?

  • It parses the request body and validates that a url field exists. Missing URLs get a 400 response immediately.
  • It calls getNextId() to atomically claim the next counter value, then passes it through encodeBase62() to produce the short code.
  • It stores the mapping (pk = short code, long_url, created_at) in DynamoDB using PutCommand.
  • It returns a 201 response containing the full short URL, the short code, and the original long URL.
  • Save shorten/index.mjs.

You'll test this live when you deploy in Step 5 and curl the endpoint in Step 6.

Seeing red squiggly lines in VS Code?

VS Code may show import errors because the AWS SDK packages aren't installed locally. This is expected. The Lambda nodejs20.x runtime includes AWS SDK v3 by default, so the imports resolve at deploy time.

If you see a different error (like a syntax issue with curly braces or parentheses), double-check that every opening { has a matching } and every ( has a matching ).

Help me debug my shorten/index.mjs file

✔️ Awesome, I've got everything!

Great. Double check you've saved shorten/index.mjs and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete shorten/index.mjs file. Compare it against yours and make sure everything matches:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;

const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

function encodeBase62(num) {
  if (num === 0) return BASE62_CHARS[0];
  let result = "";
  while (num > 0) {
    result = BASE62_CHARS[num % 62] + result;
    num = Math.floor(num / 62);
  }
  return result;
}

async function getNextId() {
  const response = await docClient.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { pk: "COUNTER" },
    UpdateExpression: "ADD counter_value :inc",
    ExpressionAttributeValues: { ":inc": 1 },
    ReturnValues: "UPDATED_NEW",
  }));
  return response.Attributes.counter_value;
}

export const handler = async (event) => {
  try {
    const body = JSON.parse(event.body);
    const longUrl = body.url;

    if (!longUrl) {
      return {
        statusCode: 400,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Missing 'url' in request body" }),
      };
    }

    const nextId = await getNextId();
    const shortCode = encodeBase62(nextId);

    await docClient.send(new PutCommand({
      TableName: TABLE_NAME,
      Item: {
        pk: shortCode,
        long_url: longUrl,
        created_at: new Date().toISOString(),
      },
    }));

    const baseUrl = `https://${event.requestContext.domainName}`;

    return {
      statusCode: 201,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        short_url: `${baseUrl}/${shortCode}`,
        short_code: shortCode,
        long_url: longUrl,
      }),
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: "Internal server error" }),
    };
  }
};

Your shorten handler is ready to generate collision-free short codes. Next up, you'll build the redirect handler that looks up those codes and sends users to the original URL.

Build the Redirect Handler

Your shorten handler can create short URLs and store them in DynamoDB. Now you need the other side of the system: the redirect handler that resolves short codes back to their original URLs.

This handler performs a single key-value lookup and returns an HTTP redirect. It needs to be fast because the redirect path handles far more traffic than the create path.

In this step, get ready to:
  • Implement the DynamoDB lookup that retrieves long URLs by their short code.
  • Return HTTP 301 redirects with caching headers for production-grade performance.
  • Handle edge cases like missing codes and invalid requests.
Set up the redirect module

The redirect handler needs to read items from DynamoDB, so it imports GetCommand instead of the write-oriented commands you used in the shorten handler.

  • Open redirect/index.mjs in VS Code and add the DynamoDB client setup by pasting this code:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

// Create a DynamoDB client and document client for simplified read operations
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;

What does this code do?

This mirrors the setup in your shorten handler, but imports only GetCommand. The redirect handler only reads from DynamoDB. It never writes, so it only needs the read command.

  • Save redirect/index.mjs.

You won't see any output yet. The next chunk builds the handler function that uses this client.

Implement the redirect handler

The redirect handler extracts the short code from the URL path, looks it up in DynamoDB, and returns an HTTP 301 redirect pointing the browser to the original long URL.

  • Add the redirect handler below the setup code you just pasted in redirect/index.mjs:
export const handler = async (event) => {
  try {
    // Extract the short code from the URL path (e.g. /abc123 -> "abc123")
    const shortCode = event.pathParameters.code;

    // Guard against missing or browser-generated requests
    if (!shortCode || shortCode === "favicon.ico") {
      return {
        statusCode: 400,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Invalid short code" }),
      };
    }

    // Look up the short code in DynamoDB
    const response = await docClient.send(new GetCommand({
      TableName: TABLE_NAME,
      Key: { pk: shortCode },
    }));

    // Return 404 if the code doesn't exist or is the internal counter item
    if (!response.Item || response.Item.pk === "COUNTER") {
      return {
        statusCode: 404,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Short URL not found" }),
      };
    }

    // Return a permanent redirect with caching headers
    return {
      statusCode: 301,
      headers: {
        Location: response.Item.long_url,
        "Cache-Control": "max-age=300",
      },
      body: "",
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: "Internal server error" }),
    };
  }
};

What does this handler do?

  • event.pathParameters.code extracts the short code from the URL path. API Gateway parses the /{code} route parameter automatically.
  • The favicon.ico guard prevents browsers from treating automatic favicon requests as short code lookups.
  • GetCommand performs a single-key lookup on the partition key. This is an O(1) operation in DynamoDB.
  • The COUNTER check prevents exposing your internal counter item as a valid short URL.
  • Status code 301 tells the browser this is a permanent redirect. The Location header contains the destination URL.
  • Cache-Control: max-age=300 lets browsers and CDNs cache the redirect for 5 minutes, reducing repeated lookups.

Why 301 instead of 302?

A 301 (permanent redirect) tells browsers and CDNs they can cache the redirect. This means repeat visitors skip your server entirely and go straight to the long URL.

A 302 (temporary redirect) forces every single click through your server. This is useful if you need real-time analytics, but it adds unnecessary load for the base case. The Secret Mission adds analytics while still using the 301 pattern.

  • Save redirect/index.mjs.

You won't see any runtime output yet. The next step defines your SAM template and deploys everything to AWS.

Seeing a syntax error in your editor?

  • Make sure you have a blank line between the TABLE_NAME line and the export const handler line. Missing separation can confuse some linters.
  • Check that you have matching curly braces and parentheses. The handler has nested try/catch, if blocks, and a return statement inside each branch.
  • Verify you imported GetCommand from @aws-sdk/lib-dynamodb on line 2. If you copied from the shorten handler, you may still have UpdateCommand or PutCommand there instead.

Help me debug a syntax error in my redirect handler for a URL shortener Lambda function.

✔️ Awesome, I've got everything!

Great. Double check you've saved redirect/index.mjs before moving on.

ⓧ I'd like to double check the full code

Here's the complete redirect/index.mjs file:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;

export const handler = async (event) => {
  try {
    const shortCode = event.pathParameters.code;

    if (!shortCode || shortCode === "favicon.ico") {
      return {
        statusCode: 400,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Invalid short code" }),
      };
    }

    const response = await docClient.send(new GetCommand({
      TableName: TABLE_NAME,
      Key: { pk: shortCode },
    }));

    if (!response.Item || response.Item.pk === "COUNTER") {
      return {
        statusCode: 404,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ error: "Short URL not found" }),
      };
    }

    return {
      statusCode: 301,
      headers: {
        Location: response.Item.long_url,
        "Cache-Control": "max-age=300",
      },
      body: "",
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      statusCode: 500,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: "Internal server error" }),
    };
  }
};

Your read path is complete. You now have both Lambda functions ready: one that creates short URLs and one that resolves them. Next up, you'll define all your infrastructure as code in a SAM template and deploy everything to AWS with a single command.

Define Infrastructure as Code and Deploy

Your Lambda functions are ready, but they only exist as files on your computer. To run them in the cloud, you need to tell AWS what resources to create: a DynamoDB table, two Lambda functions, and an HTTP API to route requests.

Instead of clicking through the AWS Console to create each resource manually, you will define everything in a single SAM template file. This is Infrastructure as Code. One file describes your entire architecture, and one command deploys it all.

In this step, get ready to:
  • Write the SAM template defining your DynamoDB table, Lambda functions, and API routes.
  • Build and deploy everything to AWS with two commands.
  • Get a live API endpoint URL you can call from anywhere.
Write the SAM template

The SAM template is a YAML file that declares every AWS resource your application needs. SAM transforms this into CloudFormation behind the scenes, which handles the actual provisioning.

  • Open template.yaml in VS Code. It should be empty.
  • Add the template header, global settings, and DynamoDB table definition by pasting the following code:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: URL Shortener - System Design Interview Project

Globals:
  Function:
    Timeout: 10
    Runtime: nodejs20.x
    Environment:
      Variables:
        TABLE_NAME: !Ref UrlTable

Resources:
  UrlTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: url-shortener-table
      BillingMode: PROVISIONED
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH

What does this code do?

The header tells AWS this is a SAM template (the Transform line). SAM extends CloudFormation with shortcuts for serverless resources.

The Globals section sets defaults for all Lambda functions: a 10-second timeout, the nodejs20.x runtime, and a TABLE_NAME environment variable that automatically resolves to the DynamoDB table name.

The UrlTable resource creates a DynamoDB table with provisioned capacity (5 read and 5 write units per second). The table uses a single partition key called pk of type String. PROVISIONED billing with 5 RCU/WCU stays within the AWS free tier (25 RCU/WCU included).

  • Below the UrlTable resource (still inside Resources:), add the ShortenFunction definition:
  ShortenFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: shorten/
      Handler: index.handler
      Events:
        Shorten:
          Type: HttpApi
          Properties:
            Path: /shorten
            Method: post
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref UrlTable

What does this code do?

This defines your first Lambda function. CodeUri: shorten/ tells SAM where to find the code. Handler: index.handler points to the exported handler function in index.mjs.

The Events section creates an API Gateway route. Type: HttpApi creates an HTTP API (API Gateway v2) that routes POST /shorten requests to this function.

DynamoDBCrudPolicy is a SAM policy template that grants read and write permissions to the DynamoDB table. Your shorten function needs both (read the counter, write the new URL).

  • Below ShortenFunction, add the RedirectFunction and the Outputs section:
  RedirectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: redirect/
      Handler: index.handler
      Events:
        Redirect:
          Type: HttpApi
          Properties:
            Path: /{code}
            Method: get
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref UrlTable

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"

What does this code do?

The RedirectFunction handles GET /{code} requests. The {code} in the path is a path parameter. When someone visits your API with a short code, API Gateway passes it to this function.

DynamoDBReadPolicy grants read-only access. The redirect function only needs to look up URLs, not write them. This separation of permissions is a security best practice called least privilege.

The Outputs section prints your API URL after deployment. ServerlessHttpApi is the implicit resource name SAM creates when you use Type: HttpApi in your function events.

  • Save template.yaml.

Why Infrastructure as Code over clicking in the console?

With IaC, your entire architecture is version-controlled and repeatable. One command creates everything. One command deletes everything. You can share the template with a teammate and they get an identical environment.

In system design interviews, this is the answer interviewers want when they ask how you would deploy a service. Manual console clicks are not reproducible or auditable.

✔️ Awesome, I've got everything!

Great. Double check you have saved template.yaml before moving on.

ⓧ I'd like to double check the full code

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: URL Shortener - System Design Interview Project

Globals:
  Function:
    Timeout: 10
    Runtime: nodejs20.x
    Environment:
      Variables:
        TABLE_NAME: !Ref UrlTable

Resources:
  UrlTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: url-shortener-table
      BillingMode: PROVISIONED
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH

  ShortenFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: shorten/
      Handler: index.handler
      Events:
        Shorten:
          Type: HttpApi
          Properties:
            Path: /shorten
            Method: post
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref UrlTable

  RedirectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: redirect/
      Handler: index.handler
      Events:
        Redirect:
          Type: HttpApi
          Properties:
            Path: /{code}
            Method: get
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref UrlTable

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"
Build the application

Before deploying, SAM needs to package your code into deployment-ready artifacts. The sam build command copies your function code into a .aws-sam/ directory, structured the way Lambda expects it.

  • Build the application by running this command:
sam build
  • You should see output ending with Build Succeeded.

Seeing a build error?

  • Make sure you are running the command from the project root directory (the folder containing template.yaml).
  • Check that template.yaml is saved and has no YAML indentation errors. YAML is sensitive to spaces.
  • Verify that shorten/index.mjs and redirect/index.mjs exist in the correct folders matching the CodeUri values in your template.

Help me debug my sam build error

Deploy to AWS

With the build complete, you are ready to push everything to AWS. The sam deploy --guided command walks you through an interactive flow to configure your deployment settings.

  • Deploy your application by running this command:
sam deploy --guided

What will SAM ask me?

SAM will prompt you with several configuration questions. Here is how to answer each one:

  • Stack Name: Type url-shortener and press Enter.
  • AWS Region: Press Enter to accept the default (your configured region).
  • Confirm changes before deploy [Y/n]: Type n and press Enter.
  • Allow SAM CLI IAM role creation [Y/n]: Press Enter to accept Y (the default). This lets SAM create the IAM roles your functions need.
  • Disable rollback [y/N]: Press Enter to accept N (the default).
  • ShortenFunction may not have authorization defined, Is this okay? [y/N]: Type y and press Enter. This confirms your API is intentionally public.
  • RedirectFunction may not have authorization defined, Is this okay? [y/N]: Type y and press Enter.
  • Save arguments to configuration file [Y/n]: Press Enter to accept Y (the default). This saves your settings to samconfig.toml for future deploys.
  • SAM configuration file and environment: Press Enter twice to accept the defaults.

The deployment takes 2-3 minutes. SAM uploads your code to S3, transforms your template into CloudFormation, and provisions all the resources.

  • Wait for the deployment to finish. You should see a table of created resources and an Outputs section at the bottom containing your ApiUrl.
  • Copy the ApiUrl value (it looks like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com). You will need this in the next step.

Deployment failed?

  • If you see a permissions error, double check that the IAM user you configured in Step 1 has the AdministratorAccess policy attached.
  • If you see a resource already exists error, another stack may have created a table with the same name. Delete the conflicting stack or change the TableName in your template.
  • If you answered N to the authorization prompt by mistake, run sam deploy --guided again and answer y this time.

Help me fix my SAM deployment error

Your entire URL shortener is now live on AWS. Next up, you will test it by creating and resolving short URLs with real HTTP requests.

Test Your Live URL Shortener

Your entire infrastructure is live in the cloud. Every Lambda function, DynamoDB table, and API Gateway route is deployed and waiting for requests.

Now you'll prove it works by sending real HTTP requests to your API. You'll test the write path (creating a short URL) and the read path (resolving a short code back to the original URL). Then you'll peek inside DynamoDB to see your data model in action.

In this step, get ready to:
  • Create a short URL by calling POST /shorten.
  • Verify the redirect works by calling GET /{code}.
  • Inspect the DynamoDB table to confirm your data model.
Create a short URL via POST /shorten

Your API Gateway endpoint URL was printed as an output when you deployed the stack. You need this URL for every test command.

  • Retrieve your API endpoint URL by running this command:
aws cloudformation describe-stacks --stack-name url-shortener --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text

What does this command do?

This queries your CloudFormation stack for the ApiUrl output value. The --query flag uses JMESPath syntax to extract just the URL string from the full stack description.

  • Copy the URL that gets printed. It looks something like https://abc123def4.execute-api.us-east-1.amazonaws.com.
  • Send a POST request to create your first short URL by running this command:

macOS

curl -X POST [[API_URL="your API Gateway endpoint URL"]]/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.example.com"}'

Windows

curl.exe -X POST [[API_URL="your API Gateway endpoint URL"]]/shorten `
  -H "Content-Type: application/json" `
  -d '{"url": "https://www.example.com"}'

Windows PowerShell and curl

In Windows PowerShell, curl is an alias for Invoke-WebRequest. You must type curl.exe to use the real curl tool. If you're using PowerShell 7+, the alias has been removed and curl works directly.

You should see a JSON response with a 201 status containing your new short URL.

What should I see?

Your response looks something like this:

{
  "short_url": "https://abc123def4.execute-api.us-east-1.amazonaws.com/1",
  "short_code": "1",
  "long_url": "https://www.example.com"
}

The short_code is 1 because this is the first URL you've shortened. The atomic counter started at 0, incremented to 1, and encodeBase62(1) returns "1".

Subsequent URLs will get codes like 2, 3, and eventually multi-character codes like a (at position 10) or 10 (at position 62).

Seeing an Internal Server Error?

If you get a 500 response, your Lambda function hit an error. Common causes:

  • Check that you included -H "Content-Type: application/json" in your curl command. Without it, the request body may not parse correctly.
  • Make sure your API URL ends without a trailing slash before /shorten. A double slash can cause routing issues.
  • If the error mentions permissions, confirm that your SAM template gives ShortenFunction the DynamoDBCrudPolicy.

Still stuck? Help me debug a 500 error from my URL shortener's POST /shorten endpoint.

Verify the redirect works via GET /{code}

The write path works. Now test the read path by resolving the short code you just created.

  • Copy the short_code value from your previous response (e.g. 1).
  • Send a GET request to your short code and inspect the response headers by running this command:

macOS

curl -i [[API_URL="your API Gateway endpoint URL"]]/[[SHORT_CODE="short_code from your POST response"]]

Windows

curl.exe -i [[API_URL="your API Gateway endpoint URL"]]/[[SHORT_CODE="short_code from your POST response"]]

What should I see?

The response shows a 301 status with two important headers:

  • Location: https://www.example.com tells the browser where to redirect.
  • Cache-Control: max-age=300 tells browsers and CDNs to cache this redirect for 5 minutes, reducing load on your system for repeated lookups.

Why 301 instead of 302?

HTTP 301 means "permanent redirect." Browsers and CDNs can cache the result, so repeat visitors never hit your server again for the same short code. HTTP 302 (temporary redirect) forces every click through your Lambda.

In a system design interview, you'd explain this trade-off. 301 reduces infrastructure costs but sacrifices per-click analytics. The Secret Mission shows how to get both.

Getting a 404 response?

  • Make sure the short code in your URL matches the short_code from the POST response exactly. It is case-sensitive.
  • Confirm you're using the same API URL as the POST command. Both Lambda functions share the same API Gateway.

Need more help? Help me debug a 404 when trying to resolve a short URL code with my redirect Lambda.

Inspect the DynamoDB table to see your data model

Your URL shortener stored two types of items in DynamoDB. One is the atomic counter that tracks the next available ID. The other is the URL mapping itself.

  • Scan your DynamoDB table to see all stored items by running this command:
aws dynamodb scan --table-name url-shortener-table

What should I see?

The scan returns two items:

  • The COUNTER item has pk set to "COUNTER" and a counter_value showing how many URLs have been shortened (currently 1).
  • The URL item has pk set to the short code ("1"), long_url with the original URL, and created_at with the timestamp.

This is the key-value lookup pattern interviewers ask about. The short code IS the primary key, so lookups are O(1) regardless of how many URLs exist in the table.

Why a single-table design?

Both the counter and the URL mappings live in the same table. The counter uses a reserved key (COUNTER) that will never collide with a Base62-encoded short code (which uses only lowercase letters, uppercase letters, and digits).

This single-table pattern avoids the overhead of managing multiple tables for a simple key-value workload. In an interview, explain that DynamoDB charges per table for provisioned capacity, so fewer tables means lower cost.

Scan returning empty results?

  • Confirm the table name matches exactly. Run aws dynamodb list-tables to see your available tables.
  • Make sure your POST /shorten command earlier returned a 201 status. A 500 means the item was never written.
  • Check that your AWS CLI is configured to the same region where you deployed the stack.

Still stuck? Help me debug why my DynamoDB scan is returning empty results for my url-shortener-table.

Your URL shortener is fully operational. You've proven the write path, read path, and data model all work end-to-end. Next up, the Secret Mission adds click analytics and URL expiration to take this from demo-ready to production-grade.

Secret mission

Add Click Analytics and URL Expiration

Real URL shorteners track every click and let links expire. Can you add both features using DynamoDB atomic counters and Time to Live?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses serverless resources that are all covered by the AWS Free Tier, so there are no ongoing costs while they sit idle.

Resources you used:

  • AWS CloudFormation stack (url-shortener) containing your DynamoDB table, Lambda functions, and API Gateway.
  • IAM user with programmatic access keys (created in Step 1).
  • Local project files on your Desktop (url-shortener folder containing template.yaml, shorten/, and redirect/).

Keep everything running

No action needed. Choose this if you plan to demo your URL shortener in interviews or continue building on it.

  • Your DynamoDB table uses 5 RCU and 5 WCU, well within the always-free 25 RCU/25 WCU limit.
  • Lambda functions only cost when invoked. The free tier covers 1 million requests per month.
  • API Gateway HTTP API calls are free for the first 1 million per month (for 12 months).
  • Your IAM user and local project files remain in place for future use.

Pause - I'll come back to this later

Serverless resources only incur cost when they are actively used. Since there are no running servers or containers, there is nothing to pause.

  • Your DynamoDB table, Lambda functions, and API Gateway will remain deployed but cost nothing while idle (within free tier limits).
  • If you want to reduce your AWS footprint without fully deleting, you can remove the IAM user's access keys from the Security credentials tab in the IAM console. You can always create new keys later.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes everything from AWS and your local machine.

Delete the CloudFormation stack
  • In your terminal, navigate to your url-shortener project folder and run:
sam delete
  • When prompted, confirm you want to delete the stack and the template file in S3 by entering y for each prompt.
  • This removes your DynamoDB table, both Lambda functions, the API Gateway, and all deployment artifacts from S3.
Delete the IAM user
  • Open the AWS Console and navigate to the IAM service.
  • In the left navigation pane, choose Users.
  • Select the checkbox next to the IAM user you created for this project.
  • Click Delete at the top of the page.
  • Enter the username in the confirmation dialog to confirm, then click Delete.
Delete local project files
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

cd ~\Desktop
rmdir /s /q url-shortener
  • Remove the stored AWS credentials for the deleted IAM user by running:

macOS

rm -rf ~/.aws

Windows

rmdir /s /q %UserProfile%\.aws

Keep AWS CLI credentials?

Only delete the .aws folder if you created it specifically for this project. If you use the AWS CLI for other work, skip this step and just remove the credentials for the deleted user from your ~/.aws/credentials file.

Nice Work!

Nice Work!

You just built and deployed a production-grade URL shortener on AWS. This is the exact system interviewers ask you to design, and now you have a live, working version you can demo.

You've learned how to:

  • Built a Base62 encoding algorithm from scratch and used DynamoDB atomic counters for collision-free short code generation.
  • Deployed two Lambda functions with separate read/write responsibilities, connected through API Gateway HTTP routes.
  • Defined all infrastructure as code with AWS SAM, deployable and deletable in a single command.
  • Added click analytics and URL expiration with DynamoDB TTL for production-grade completeness.

Ready to quiz yourself?