Build a URL Shortener on AWS
Build a mini bit.ly with API Gateway, Lambda, and DynamoDB.
Introduction
30 Second Summary
Every time you click a short link on social media or in a text message, a system behind the scenes looks up where to send you. That quick redirect involves a client, an API, server logic, and a database all working together in milliseconds.
In this project, you will build your own URL shortener on AWS to see exactly how these system components connect. You will create a working cloud backend and a simple frontend that shortens URLs and redirects visitors.
What You'll Build
A fully functional URL shortener (like a mini bit.ly) with a serverless cloud backend on AWS and a simple webpage that connects to it.
By the end of this project, you'll have:
- A DynamoDB table storing URL mappings as your database layer.
- A Lambda function handling shorten and redirect logic as your application layer.
- An API Gateway endpoint accessible from anywhere on the internet as your API layer.
- A simple webpage that calls your API to shorten URLs and test redirects.
- Secret Mission: Add a click counter that tracks how many times each short URL has been visited.
Are there any prerequisites?
You should have basic HTML, CSS, and beginner JavaScript knowledge. You also need a VS Code installation and an AWS free tier account. All hands-on work happens in the AWS Console and your local text editor.
Before We Start
Before We Start...
Understand the System Architecture
Before building anything in AWS, you need a clear picture of how all the pieces fit together. Every web application follows the same fundamental pattern: a client sends a request, a server processes it, and a database stores data.
In this step, you'll map out your URL shortener's architecture in a document. This gives you a reference to follow as you build each component in the steps ahead.
In this step, get ready to:
- Diagram the request-response cycle your URL shortener uses.
- Map each AWS service to its system design role.
- Record the key design decisions behind your architecture.
Draw the system diagram
Every URL shortener follows a request-response cycle. A request comes in from the user's browser, travels through several services, and a response goes back. Documenting this flow first helps you understand what you're wiring together in later steps.
- In VS Code, create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Save the file immediately by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Save it to your Desktop with the filename architecture.md.
- Paste the following data flow diagram into the file:
# URL Shortener - System Architecture
## Data Flow
### Shorten a URL (Write Path)
User's Browser → API Gateway (POST /shorten) → Lambda Function → DynamoDB (save mapping) → Lambda → API Gateway → User's Browser (returns short URL)
### Redirect (Read Path)
User's Browser → API Gateway (GET /{shortCode}) → Lambda Function → DynamoDB (lookup mapping) → Lambda → API Gateway → User's Browser (302 redirect)
What does this diagram show?
You've drawn two paths that your system supports:
- The Write Path is what happens when someone submits a long URL to be shortened. The request flows forward through every service, then the response flows back.
- The Read Path is what happens when someone visits a short URL. The system looks up the original URL and sends the user there with a 302 redirect.
This round-trip pattern is the request-response cycle. It's how every web application works.
- Save architecture.md.
- You should see the heading and two data flow paths displayed in your editor.
File saved as plain text instead of Markdown?
Make sure the filename ends in .md. If VS Code shows "Plain Text" in the bottom-right corner of the window, click it and select Markdown from the list.
Help me save my file as Markdown in VS Code.
Map each component to its role
Each AWS service in your system plays a specific role. API Gateway is the front door. Lambda is the brain. DynamoDB is the memory. Thinking in these roles helps you design systems at a higher level, regardless of which specific tools you use.
- In architecture.md, add the following component roles table below the Data Flow section:
## Component Roles
| Component | System Design Role | AWS Service |
|-----------|-------------------|-------------|
| Front Door | Receives requests from the internet, routes them | API Gateway |
| Brain | Contains business logic (generate codes, look up URLs) | Lambda |
| Memory | Stores data persistently (URL mappings) | DynamoDB |
| Client | User interface that sends requests | HTML page in browser |
Why use role names like Front Door and Brain?
These role names describe what each component does in a system, separate from the specific AWS service filling that role. A "front door" could be API Gateway today, or Nginx tomorrow. The pattern stays the same.
When you interview or discuss system design, people think in these generic roles. The specific vendor tools are interchangeable.
- Save architecture.md.
- You should see the component roles table rendered below your data flow section.
Table looks like a jumble of pipes and dashes?
That's normal in the code editor view. Markdown tables only render as formatted tables when previewed. You can preview by pressing Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows) in VS Code.
Help me preview my Markdown file in VS Code.
Understand the two operations
Your system handles two distinct types of requests, each using a different HTTP method. POST /shorten takes a long URL, generates a short code, saves the mapping, and returns the short URL. GET /{shortCode} looks up the short code in the database and redirects the user to the original URL.
What is a key-value lookup?
When someone visits a short URL, your system needs to find the original URL using only the short code. This is called a key-value lookup. You give the system a key (shortCode) and it returns the matching value (longUrl). DynamoDB is purpose-built for this exact pattern.
- Add the following design decisions section to the bottom of architecture.md:
## Design Decisions
- **Partition key = shortCode**: Optimized for the most frequent operation (redirect lookups)
- **Serverless**: No servers to manage, scales automatically, pay only for what you use
- **Single Lambda function**: Handles all routes (simpler for learning, production would split these)
What does this section capture?
- Partition key = shortCode means DynamoDB is optimized for the most common operation: looking up a URL by its short code.
- Serverless means you don't manage any servers. AWS handles scaling and availability for you.
- Single Lambda function keeps things simple for learning. A production system would split routes into separate functions.
- Save architecture.md.
- You should see all three sections in your file: Data Flow, Component Roles, and Design Decisions.
Missing a section?
Make sure each section starts with a ## heading on its own line. Check that you haven't accidentally pasted a new section inside the previous one.
Help me fix my architecture.md structure.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to the next step.
ⓧ I'd like to double check the full code
Here's the complete architecture.md file. Make sure yours matches this exactly:
# URL Shortener - System Architecture
## Data Flow
### Shorten a URL (Write Path)
User's Browser → API Gateway (POST /shorten) → Lambda Function → DynamoDB (save mapping) → Lambda → API Gateway → User's Browser (returns short URL)
### Redirect (Read Path)
User's Browser → API Gateway (GET /{shortCode}) → Lambda Function → DynamoDB (lookup mapping) → Lambda → API Gateway → User's Browser (302 redirect)
## Component Roles
| Component | System Design Role | AWS Service |
|-----------|-------------------|-------------|
| Front Door | Receives requests from the internet, routes them | API Gateway |
| Brain | Contains business logic (generate codes, look up URLs) | Lambda |
| Memory | Stores data persistently (URL mappings) | DynamoDB |
| Client | User interface that sends requests | HTML page in browser |
## Design Decisions
- **Partition key = shortCode**: Optimized for the most frequent operation (redirect lookups)
- **Serverless**: No servers to manage, scales automatically, pay only for what you use
- **Single Lambda function**: Handles all routes (simpler for learning, production would split these)
Your architecture blueprint is ready. Next up, you'll bring the first component to life by creating the DynamoDB table that stores your URL mappings.
Create the Database
You've mapped out how your system's four components connect. Now it's time to start building them in the cloud, starting with the database layer.
Every URL shortener needs somewhere to remember which short code maps to which long URL. DynamoDB is your system's "memory" layer. It stores these mappings so they persist even when no one is using the app.
In this step, get ready to:
- Create a DynamoDB table to store URL mappings.
- Understand why the partition key design matters.
- Verify the table is active and ready to use.
Create the DynamoDB table
Your database needs just one table. It maps a short code (like AbC123) to the original long URL. You'll create this table in the AWS Management Console.
- Open your browser and sign in to the AWS Management Console at console.aws.amazon.com.
- In the search bar at the top, type DynamoDB and select it from the results.
- In the left navigation pane, choose Tables.
- Click Create table.
- In the Table name field, enter url-shortener-links.
- In the Partition key field, enter shortCode.
- Make sure the type dropdown next to the partition key is set to String.
- For Table settings, keep the default selection of Default settings.
- Click Create table.
What is a partition key?
A partition key is the primary way DynamoDB identifies and retrieves items. Think of it like a unique label on a filing cabinet drawer. When you ask for a specific drawer (short code), DynamoDB goes directly to it without scanning every other drawer.
You chose shortCode as the partition key because the most frequent operation in a URL shortener is looking up the original URL by its short code. DynamoDB is optimized for exactly this type of fast, single-item lookup.
Understand the design decision
The choice of shortCode as the partition key is not arbitrary. It reflects a core system design pattern called a key-value lookup.
Why shortCode as the partition key?
When someone visits a short URL, your system needs to find the original URL as fast as possible. The short code is the only information available in the request. By making it the partition key, DynamoDB can retrieve the matching record in single-digit milliseconds without scanning the entire table.
This is one of the most common patterns in system design. Every caching layer, session store, and URL shortener uses the same idea: give me data by its unique key, instantly.
Verify the table is active
DynamoDB creates tables asynchronously. The table starts in a CREATING state and transitions to ACTIVE when it is ready to accept reads and writes.
- Wait until the table status shows Active in the DynamoDB console. This usually takes just a few seconds.
- Click on the table name url-shortener-links to open the table details.
- Click the Explore table items tab.
You should see a message indicating there are no items in the table. This confirms the table is empty and ready to store URL mappings.
Table stuck in Creating status?
DynamoDB tables typically become active within a few seconds. If your table shows Creating for more than 30 seconds, try refreshing the page. If it still does not appear, check that you are looking at the correct AWS Region in the top-right corner of the console.
Help me figure out why my DynamoDB table isn't becoming active.
Your database is live and waiting for data. Next up, you'll create the Lambda function that reads and writes to this table.
Create the Lambda Function
Your database is ready and waiting for data. Now you need the "brain" of your system.
In system design, the application layer is where your business logic lives. For a URL shortener, that means generating short codes, saving mappings to the database, and redirecting users. Lambda is the AWS service that runs your code without needing you to manage a server.
In this step, get ready to:
- Create a Lambda function with the right permissions to access DynamoDB.
- Add the URL shortener logic that handles two routes.
- Understand how the code processes requests and responses.
Create the function
Every Lambda function needs an IAM role that defines what AWS services it can access. You will create the function and its role at the same time using a policy template that grants DynamoDB access.
- In the AWS Console, type Lambda into the search bar at the top and select Lambda from the results.
- Click Create function.
- Leave Author from scratch selected.
- For Function name, enter url-shortener-function.
- For Runtime, choose Node.js 22.x.
- Expand the Change default execution role section under Permissions.
- Select Create a new role from AWS policy templates.
- For Role name, enter url-shortener-role.
- For Policy templates, search for and select Simple microservice permissions.
- Click Create function.
What is Simple microservice permissions?
This is a pre-built policy template that grants your Lambda function permission to read from and write to DynamoDB tables. Without it, your function would be blocked from accessing the database even if the code is correct.
Wait a few seconds for the function to be created. You should see a success notification and the Lambda code editor should appear.
Don't see the code editor?
If the page stays on the Create function form, scroll up to check for error messages. Common causes include a function name that already exists or a role name conflict. Try a slightly different name if needed.
help me troubleshoot why my Lambda function didn't create
Add the function code
The Lambda code editor opens with a default handler file. You need to replace it with the URL shortener logic that handles creating short URLs and redirecting users.
- In the code editor, select all the existing code and delete it.
- Paste the following code into the editor:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
} from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(client);
const tableName = "url-shortener-links";
function generateShortCode() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export const handler = async (event) => {
let body;
let statusCode = 200;
const headers = {
"Content-Type": "application/json",
};
try {
switch (event.routeKey) {
case "POST /shorten": {
const requestBody = JSON.parse(event.body);
const longUrl = requestBody.url;
if (!longUrl) {
statusCode = 400;
body = { error: "Missing 'url' in request body" };
break;
}
const shortCode = generateShortCode();
await dynamo.send(
new PutCommand({
TableName: tableName,
Item: {
shortCode: shortCode,
longUrl: longUrl,
createdAt: new Date().toISOString(),
},
})
);
const apiUrl = `https://${event.requestContext.domainName}`;
body = {
shortUrl: `${apiUrl}/${shortCode}`,
shortCode: shortCode,
};
statusCode = 201;
break;
}
case "GET /{shortCode}": {
const shortCode = event.pathParameters.shortCode;
const result = await dynamo.send(
new GetCommand({
TableName: tableName,
Key: { shortCode: shortCode },
})
);
if (!result.Item) {
statusCode = 404;
body = { error: "Short URL not found" };
break;
}
return {
statusCode: 302,
headers: {
Location: result.Item.longUrl,
},
body: "",
};
}
default:
statusCode = 400;
body = { error: `Unsupported route: ${event.routeKey}` };
}
} catch (err) {
statusCode = 500;
body = { error: err.message };
}
return {
statusCode,
body: JSON.stringify(body),
headers,
};
};
- Click the Deploy button above the code editor to save your function.
You should see a notification confirming the function was updated successfully.
Seeing a deployment error?
Make sure you deleted all of the default code before pasting. Leftover characters at the top or bottom of the file can cause syntax errors. Try selecting all with Cmd+A (macOS) or Ctrl+A (Windows), delete, and paste again.
help me fix my Lambda deployment error
✔️ Awesome, I've got everything!
Great. Make sure you clicked Deploy and saw the success notification before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.mjs file that should be in your Lambda code editor:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
} from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(client);
const tableName = "url-shortener-links";
function generateShortCode() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export const handler = async (event) => {
let body;
let statusCode = 200;
const headers = {
"Content-Type": "application/json",
};
try {
switch (event.routeKey) {
case "POST /shorten": {
const requestBody = JSON.parse(event.body);
const longUrl = requestBody.url;
if (!longUrl) {
statusCode = 400;
body = { error: "Missing 'url' in request body" };
break;
}
const shortCode = generateShortCode();
await dynamo.send(
new PutCommand({
TableName: tableName,
Item: {
shortCode: shortCode,
longUrl: longUrl,
createdAt: new Date().toISOString(),
},
})
);
const apiUrl = `https://${event.requestContext.domainName}`;
body = {
shortUrl: `${apiUrl}/${shortCode}`,
shortCode: shortCode,
};
statusCode = 201;
break;
}
case "GET /{shortCode}": {
const shortCode = event.pathParameters.shortCode;
const result = await dynamo.send(
new GetCommand({
TableName: tableName,
Key: { shortCode: shortCode },
})
);
if (!result.Item) {
statusCode = 404;
body = { error: "Short URL not found" };
break;
}
return {
statusCode: 302,
headers: {
Location: result.Item.longUrl,
},
body: "",
};
}
default:
statusCode = 400;
body = { error: `Unsupported route: ${event.routeKey}` };
}
} catch (err) {
statusCode = 500;
body = { error: err.message };
}
return {
statusCode,
body: JSON.stringify(body),
headers,
};
};
Understand what the code does
This single function handles two completely different operations depending on how the request arrives. The routing pattern is what makes one Lambda function serve multiple purposes.
How does the routing work?
The function reads event.routeKey to figure out what the user wants. API Gateway passes this value in the format METHOD /path (for example, POST /shorten or GET /{shortCode}). The switch statement acts like a traffic controller, sending each request to the right block of logic.
What happens for each route?
For POST /shorten: the function parses the incoming JSON body, generates a random 6-character code using letters and numbers, saves the mapping (short code to original URL) into your DynamoDB table, and returns the new short URL.
For GET /{shortCode}: the function extracts the short code from the URL path, looks it up in DynamoDB, and returns a 302 redirect response. The browser automatically follows this redirect and takes the user to the original URL.
Why does the function return different response shapes?
The shorten route returns JSON data (the short URL) with a 201 status code, meaning "created successfully." The redirect route returns a 302 status with a Location header instead of a JSON body. This tells the browser to navigate to a different URL. These are standard HTTP patterns that every web application uses.
Your Lambda function is deployed and ready to process requests. Next up, you will create the API Gateway that connects this function to the internet so it can receive real HTTP requests.
Create the API Gateway
Your Lambda function is deployed and ready to process requests. But right now, there is no way for the outside world to reach it.
In this step, you'll create the "front door" of your system. API Gateway is the AWS service that sits between the internet and your Lambda function, receiving HTTP requests and routing them to the right place.
In this step, get ready to:
- Create an HTTP API in API Gateway.
- Define two routes that map to your URL shortener operations.
- Connect both routes to your Lambda function.
Create the HTTP API
API Gateway offers two types of APIs: REST APIs and HTTP APIs. HTTP APIs are simpler, cheaper, and perfect for this project.
- In the AWS Console search bar at the top, type API Gateway and select the service.
- Click Create API.
- Find the HTTP API section and click Build.
- For API name, enter url-shortener-api.
- For IP address type, select IPv4.
- Click Next.
- On the Configure routes page, click Next to skip route creation for now.
- Review the default stage that API Gateway creates for you. Click Next.
- Click Create.
What is an HTTP API?
An HTTP API is a lightweight entry point that accepts requests from the internet and routes them to your backend services. Think of it like a receptionist who listens for incoming calls and directs them to the right person.
API Gateway automatically creates a $default stage with automatic deployments enabled. This means any changes you make to routes or integrations go live immediately.
Don't see HTTP API as an option?
Make sure you're looking for HTTP API (not REST API). REST API is a different, more complex product. The HTTP API option should appear near the top of the page with a Build button.
help me find the HTTP API option in the API Gateway console
Create the routes
Routes tell API Gateway which requests go where. Each route is a combination of an HTTP method and a path. You need two routes: one for creating short URLs and one for redirecting visitors.
- In your new API, click Routes in the left sidebar.
- Click Create.
- For Method, choose POST.
- For the path, enter /shorten.
- Click Create.
Now create the second route for redirects.
- Click Create again.
- For Method, choose GET.
- For the path, enter /{shortCode}.
- Click Create.
You should now see both routes listed: POST /shorten and GET /{shortCode}.
What is a path parameter?
The {shortCode} part wrapped in curly braces is a path parameter. It acts as a placeholder that captures whatever value appears in that position of the URL.
For example, when someone visits your-api.com/abc123, API Gateway captures abc123 as the shortCode value and passes it to your Lambda function.
Seeing a route you didn't mean to create?
If you accidentally created a wrong route, select it and click Delete. Then create the correct one. Double check that you chose the right HTTP method (POST vs GET) for each path.
help me fix my API Gateway routes
Connect Lambda to the routes
Right now your routes exist but they don't know where to send requests. You need to create an integration that connects your routes to the Lambda function you built earlier.
- Click Integrations in the left sidebar.
- Click Manage integrations, then click Create.
- For Integration type, choose Lambda function.
- For Lambda function, enter url-shortener-function.
- Click Create.
Now you need to attach this integration to both of your routes.
- Go back to Integrations and click Attach integrations to routes.
- Select the POST /shorten route.
- Under Choose an existing integration, select the url-shortener-function integration.
- Click Attach integration.
- Repeat for the GET /{shortCode} route: select it, choose the same url-shortener-function integration, and click Attach integration.
Both routes should now show the Lambda integration attached. You can verify this on the Integrations page where each route displays its connected Lambda function.
Why one Lambda for both routes?
In a production system, you would typically have separate Lambda functions for each route. This keeps each function focused on one task.
For learning, a single function with a route switch makes it easier to see the full picture of how requests flow through your system. Your Lambda code uses event.routeKey to determine which route triggered it and respond accordingly.
Don't see your Lambda function in the dropdown?
Make sure you're in the same AWS region where you created the Lambda function. Check the region selector in the top-right corner of the console. Your Lambda function url-shortener-function must be in the same region as your API.
help me connect my Lambda function to API Gateway
Your API is now live on the internet. Next up, you'll test the full system by sending real requests to your API and watching URLs get shortened and redirected.
Test Your API
You've built all three layers of your backend system. The database stores data, the Lambda function processes requests, and the API Gateway receives traffic from the internet.
Now it's time to prove everything works together. You'll send a real HTTP request to your live API and watch the entire request-response cycle fire end-to-end.
In this step, get ready to:
- Find your API's Invoke URL.
- Send a POST request to shorten a URL.
- Verify the redirect works in your browser.
Find your API URL
Every API Gateway HTTP API has a unique Invoke URL. This is the public address that anyone on the internet can use to reach your API.
- Return to the API Gateway console in your browser.
- Click on your API name url-shortener-api in the list.
- On the main details page, find the Invoke URL field.
- Copy this URL. It looks like https://xxxxxxxx.execute-api.us-east-1.amazonaws.com (your ID and region will be different).
What is the Invoke URL?
The Invoke URL is your API's public address on the internet. When you append a route like /shorten to the end, API Gateway knows which Lambda function to call.
This is the "front door" from your architecture diagram. Every request to your system starts here.
Test creating a short URL
You'll send an HTTP POST request to your API with a long URL, and your system will return a short one. This exercises the full write path: API Gateway receives the request, Lambda generates a short code, and DynamoDB saves the mapping.
- Open a terminal on your computer. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Send a POST request to shorten a URL by running this command:
macOS
curl -X POST -H "Content-Type: application/json" -d '{"url": "https://www.wikipedia.org"}' [[API_INVOKE_URL="https://xxxxxxxx.execute-api.us-east-1.amazonaws.com"]]/shorten
Windows
curl.exe -X POST -H "Content-Type: application/json" -d "{\"url\": \"https://www.wikipedia.org\"}" [[API_INVOKE_URL="https://xxxxxxxx.execute-api.us-east-1.amazonaws.com"]]/shorten
What does this command do?
- curl sends an HTTP request from your terminal (Windows uses curl.exe to avoid a PowerShell alias).
- -X POST tells the server you're sending data, not just reading.
- -H "Content-Type: application/json" tells the server the data is in JSON format.
- -d '{"url": "..."}' is the actual data you're sending. Your Lambda function reads this to get the long URL.
You should see a JSON response that looks something like this:
{
"shortUrl": "https://xxxxxxxx.execute-api.us-east-1.amazonaws.com/AbCdEf",
"shortCode": "AbCdEf"
}
The exact characters in your short code will be different. That's expected because the code is randomly generated.
Seeing an error instead?
- If you see Internal Server Error, check that your Lambda function was deployed (click Deploy in the Lambda code editor).
- If you see Not Found, double check that you pasted the Invoke URL correctly and added /shorten at the end.
- If you get a permissions error, verify that your Lambda function's role (url-shortener-role) has the Simple microservice permissions policy attached.
My curl command is returning an error. What should I check?
Test the redirect
Now you'll test the read path. When someone visits your short URL, the system looks up the short code in DynamoDB and redirects the visitor to the original long URL.
- Copy the full shortUrl value from the JSON response in your terminal.
- Paste it into your browser's address bar and press Enter.
Your browser should redirect you to Wikipedia. You've just triggered the full read path: your browser hit API Gateway, which called Lambda, which looked up the code in DynamoDB, and returned a 302 redirect.
What is a 302 redirect?
A 302 is an HTTP status code that tells the browser "the page you want has temporarily moved to this other URL." Your Lambda function returns 302 with a Location header pointing to the original long URL.
The browser sees 302 and automatically navigates to the new URL. This all happens so fast that the user just sees the final destination page.
- To confirm the data was saved, switch back to the DynamoDB console.
- Click on your url-shortener-links table.
- Click the Explore table items tab.
- You should see one item with your shortCode, longUrl, and createdAt attributes.
Not redirecting to Wikipedia?
- Make sure you copied the entire shortUrl value including the https:// prefix.
- If you see a JSON error Short URL not found, the short code might be wrong. Copy the URL directly from the terminal output without modifying it.
- Check the DynamoDB table for items. If it's empty, the POST request didn't save correctly. Re-run the curl command and look for errors.
My short URL isn't redirecting. What could be wrong?
Your API works end-to-end. Next up, you'll build a frontend webpage that connects to this backend so users can shorten URLs without touching a terminal.
Build the Frontend
Your backend is live and tested. You proved it works with PowerShell, but real users need something friendlier than a terminal command.
In this step, you will build a simple webpage that connects directly to your API Gateway endpoint. This completes the client layer of your system architecture.
In this step, get ready to:
- Create an HTML page with a styled input form and result area.
- Add JavaScript that sends requests to your API and displays short URLs.
- Enable CORS on your API and test the full system end-to-end.
Create the HTML page
Your frontend needs three visible elements: a text input for pasting a long URL, a button to trigger the shortening, and an area to display the result.
- Switch back to VS Code.
- Create a new file called index.html in the same folder as your architecture.md file.
- Paste the following HTML and CSS into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL Shortener</title>
<style>
/* Reset default browser spacing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Center the card on the page */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f0f4f8;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
/* White card container */
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
}
h1 {
color: #1a202c;
margin-bottom: 8px;
font-size: 24px;
}
.subtitle {
color: #718096;
margin-bottom: 24px;
font-size: 14px;
}
/* Input and button side by side */
.input-group {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: #4299e1;
}
button {
padding: 12px 24px;
background-color: #4299e1;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #3182ce;
}
button:disabled {
background-color: #a0aec0;
cursor: not-allowed;
}
/* Green box for successful result */
.result {
display: none;
background-color: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 8px;
padding: 16px;
}
.result.visible {
display: block;
}
.result-label {
font-size: 12px;
color: #38a169;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
}
.result-link {
color: #2b6cb0;
font-size: 14px;
word-break: break-all;
}
/* Red box for errors */
.error {
display: none;
background-color: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 16px;
color: #c53030;
font-size: 14px;
}
.error.visible {
display: block;
}
</style>
</head>
<body>
<div class="container">
<h1>URL Shortener</h1>
<p class="subtitle">Paste a long URL to get a short link</p>
<div class="input-group">
<input type="url" id="urlInput" placeholder="https://example.com/very/long/url">
<button id="shortenBtn" onclick="shortenUrl()">Shorten</button>
</div>
<div class="result" id="result">
<p class="result-label">Your short URL</p>
<a class="result-link" id="shortUrl" href="#" target="_blank"></a>
</div>
<div class="error" id="error"></div>
</div>
</body>
</html>
What does this code do?
The <style> section creates a clean card-based design with a centered white container, a blue button, a green result box (hidden by default), and a red error box (also hidden by default).
The <body> contains the visible structure: an input field with id="urlInput", a button that calls shortenUrl() on click, a result div that appears when a URL is shortened, and an error div that appears when something goes wrong.
- Save index.html.
- Open index.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows).
- You should see a white card in the center of the page with a URL input field and a blue Shorten button.
Don't see the styled card?
Make sure you saved the file with the .html extension, not .txt. If the page looks like raw code, your browser is reading it as plain text instead of HTML.
Help me fix this
Add the JavaScript
The page looks right, but clicking the button does nothing yet. You need JavaScript to send a request to your API Gateway endpoint and display the result.
- In index.html, find the closing </div> tag that sits just above </body>.
- Add the following <script> block between that </div> and </body>:
<script>
// Replace this with your actual API Gateway Invoke URL
const API_URL = "https://your-api-id.execute-api.us-east-1.amazonaws.com";
async function shortenUrl() {
// Grab references to the page elements
const urlInput = document.getElementById("urlInput");
const resultDiv = document.getElementById("result");
const shortUrlLink = document.getElementById("shortUrl");
const errorDiv = document.getElementById("error");
const button = document.getElementById("shortenBtn");
// Hide any previous results or errors
resultDiv.classList.remove("visible");
errorDiv.classList.remove("visible");
// Validate that the user entered something
const longUrl = urlInput.value.trim();
if (!longUrl) {
errorDiv.textContent = "Please enter a URL";
errorDiv.classList.add("visible");
return;
}
// Disable the button while the request is in progress
button.disabled = true;
button.textContent = "Shortening...";
try {
// Send the URL to your API Gateway endpoint
const response = await fetch(`${API_URL}/shorten`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: longUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Something went wrong");
}
// Display the short URL as a clickable link
shortUrlLink.href = data.shortUrl;
shortUrlLink.textContent = data.shortUrl;
resultDiv.classList.add("visible");
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.add("visible");
} finally {
// Re-enable the button regardless of success or failure
button.disabled = false;
button.textContent = "Shorten";
}
}
// Allow pressing Enter to trigger the shorten action
document.getElementById("urlInput").addEventListener("keypress", function(e) {
if (e.key === "Enter") {
shortenUrl();
}
});
</script>
What does this code do?
The API_URL constant holds your API Gateway Invoke URL. The shortenUrl() function reads the input field, sends a POST request to your /shorten endpoint, and displays the returned short URL as a clickable link.
If the request fails, the error message appears in the red box. The finally block re-enables the button no matter what happens. The event listener at the bottom lets users press Enter instead of clicking the button.
- Replace https://your-api-id.execute-api.us-east-1.amazonaws.com in the API_URL line with your actual Invoke URL from the previous step.
- Save index.html.
Where to find your Invoke URL
Return to the API Gateway console, select your url-shortener-api, and look for the Invoke URL on the main API page. It looks like https://xxxxxxxx.execute-api.us-east-1.amazonaws.com.
Make sure you do NOT include a trailing slash at the end.
Not sure where the script goes?
The <script> tag goes after the closing </div> of the container and before the closing </body> tag. If you are unsure, use the full code check tab below to compare your file.
Help me place the script tag
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.html with your real Invoke URL in the API_URL line.
ⓧ I'd like to double check the full code
Here is the complete index.html file. Make sure yours matches (except your own Invoke URL in the API_URL line):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URL Shortener</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f0f4f8;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
}
h1 {
color: #1a202c;
margin-bottom: 8px;
font-size: 24px;
}
.subtitle {
color: #718096;
margin-bottom: 24px;
font-size: 14px;
}
.input-group {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: #4299e1;
}
button {
padding: 12px 24px;
background-color: #4299e1;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #3182ce;
}
button:disabled {
background-color: #a0aec0;
cursor: not-allowed;
}
.result {
display: none;
background-color: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 8px;
padding: 16px;
}
.result.visible {
display: block;
}
.result-label {
font-size: 12px;
color: #38a169;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
}
.result-link {
color: #2b6cb0;
font-size: 14px;
word-break: break-all;
}
.error {
display: none;
background-color: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 16px;
color: #c53030;
font-size: 14px;
}
.error.visible {
display: block;
}
</style>
</head>
<body>
<div class="container">
<h1>URL Shortener</h1>
<p class="subtitle">Paste a long URL to get a short link</p>
<div class="input-group">
<input type="url" id="urlInput" placeholder="https://example.com/very/long/url">
<button id="shortenBtn" onclick="shortenUrl()">Shorten</button>
</div>
<div class="result" id="result">
<p class="result-label">Your short URL</p>
<a class="result-link" id="shortUrl" href="#" target="_blank"></a>
</div>
<div class="error" id="error"></div>
</div>
<script>
const API_URL = "https://your-api-id.execute-api.us-east-1.amazonaws.com";
async function shortenUrl() {
const urlInput = document.getElementById("urlInput");
const resultDiv = document.getElementById("result");
const shortUrlLink = document.getElementById("shortUrl");
const errorDiv = document.getElementById("error");
const button = document.getElementById("shortenBtn");
resultDiv.classList.remove("visible");
errorDiv.classList.remove("visible");
const longUrl = urlInput.value.trim();
if (!longUrl) {
errorDiv.textContent = "Please enter a URL";
errorDiv.classList.add("visible");
return;
}
button.disabled = true;
button.textContent = "Shortening...";
try {
const response = await fetch(`${API_URL}/shorten`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: longUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Something went wrong");
}
shortUrlLink.href = data.shortUrl;
shortUrlLink.textContent = data.shortUrl;
resultDiv.classList.add("visible");
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.add("visible");
} finally {
button.disabled = false;
button.textContent = "Shorten";
}
}
document.getElementById("urlInput").addEventListener("keypress", function(e) {
if (e.key === "Enter") {
shortenUrl();
}
});
</script>
</body>
</html>
Test the full system
Before your browser can talk to your API, you need to enable CORS on your API Gateway. Browsers block requests from a local HTML file to a different domain unless the server explicitly allows it.
- Return to the API Gateway console and select your url-shortener-api.
- In the left navigation pane, click CORS.
- Click Configure.
- In the Access-Control-Allow-Origin field, enter * and click Add.
- In the Access-Control-Allow-Headers field, enter content-type and click Add.
- In the Access-Control-Allow-Methods field, select POST and click Add.
- Click Save.
What is CORS?
CORS (Cross-Origin Resource Sharing) is a browser security feature. When your local HTML file calls your API hosted on a different domain, the browser first sends a preflight request to check if the server allows it.
By setting Access-Control-Allow-Origin to *, you tell the browser that any origin is allowed to call your API. For a learning project this is fine. A production app would restrict this to specific domains.
Now test your frontend end-to-end.
- Refresh index.html in your browser.
- Paste a long URL into the input field (for example, https://www.wikipedia.org/wiki/System_design).
- Click Shorten.
- You should see a green box appear with your short URL displayed as a clickable link.
- Click the short URL link to confirm it redirects you to the original page.
Seeing a CORS error in the browser console?
Open your browser's Developer Tools (press F12 or right-click and select Inspect) and check the Console tab for errors.
Make sure you added * to the Access-Control-Allow-Origin field in your API's CORS settings and clicked Save. HTTP APIs auto-deploy CORS changes, so no manual redeployment is needed.
Help me debug this CORS error
Button says Shortening but nothing happens?
Double-check that the API_URL value in your JavaScript matches your actual Invoke URL exactly. It should NOT have a trailing slash at the end.
Also confirm the URL starts with https:// and not http://.
Help me troubleshoot my API connection
You just built a complete distributed system. Your browser sends a request to API Gateway, which triggers Lambda, which reads and writes to DynamoDB, and the response flows all the way back to your webpage. Next up, a secret mission to add analytics tracking.
Secret mission
Add a Click Counter
Your URL shortener works, but you have no idea which links are popular. Real systems track analytics on every interaction. Can you add a click counter that increments every time someone visits a short URL, then expose a stats endpoint so you can check how many clicks each link has received?
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. All resources in this project fall within the AWS Free Tier, so there are no ongoing costs at this scale.
Resources you used:
- API Gateway HTTP API (url-shortener-api).
- Lambda function (url-shortener-function).
- DynamoDB table (url-shortener-links).
- IAM role (url-shortener-role).
- CloudWatch log group (/aws/lambda/url-shortener-function).
- Local project files (architecture.md, index.html, index.mjs).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your URL shortener.
- Lambda and DynamoDB have always-free tiers at this usage level (1 million requests per month for Lambda, 25 GB storage and 25 read/write capacity units for DynamoDB).
- API Gateway offers 1 million free HTTP API calls per month for 12 months. After that, costs only apply if your API receives actual traffic.
- At this project's scale, your resources will stay within free tier indefinitely.
Pause - I'll come back to this later
Since all resources in this project are serverless, there is nothing actively running to pause. Your Lambda function, DynamoDB table, and API Gateway only consume resources when they receive requests.
- Leave everything as-is. You will not be charged while your API sits idle.
- When you are ready to come back, your API URL and all stored short URLs will still work.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- In the AWS Console, navigate to API Gateway. Select url-shortener-api from the APIs list, then click Delete and confirm.
- Navigate to Lambda. Select url-shortener-function, click Actions, then choose Delete function and confirm.
- Navigate to DynamoDB, then Tables. Select url-shortener-links and click Delete table. Type confirm when prompted.
- Navigate to IAM, then Roles. Search for url-shortener-role, select it, and click Delete. Type the role name to confirm.
- Navigate to CloudWatch, then Log groups. Select /aws/lambda/url-shortener-function, click Actions, and choose Delete log group(s). Confirm the deletion.
- Delete the local project files (architecture.md, index.html, and index.mjs) from your computer.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional URL shortener from scratch and deployed it on AWS.
You've learned how to:
- Build a serverless backend using DynamoDB, Lambda, and API Gateway working together as a complete system.
- Connect a frontend client to a cloud backend, completing the full client-server architecture that powers every web application.
- Map each component to its system design role (database layer, application layer, API layer, client layer) and trace the request-response cycle end-to-end.
- Add analytics tracking with atomic counters to monitor how often each short URL is visited.
Ready to quiz yourself?