How to Build a URL Shortener Web App from Scratch
Introduction
URL shorteners are everywhere in today's internet. Services like bit.ly, TinyURL, and goo.gl have become essential tools for sharing links on social media, in emails, and anywhere character limits matter. But have you ever wondered how these services actually work under the hood?
In this tutorial, you'll build a fully functional URL shortener from scratch using Node.js and Express. By the end, you'll understand the core concepts behind URL shortening and have a working application that you can expand upon.
A complete URL shortener web application that:
- Accepts long URLs and generates short codes
- Stores URL mappings in a database
- Redirects short URLs to the original long URLs
- Includes a simple web interface for creating shortened links
- Tracks click counts for analytics
- Building RESTful APIs with Express.js
- Working with SQLite for data persistence
- Generating unique short codes
- Implementing 301 redirects
- Basic frontend development
How URL Shorteners Work
Before we start coding, let's understand the fundamental concepts behind URL shortening.
The Core Concept
A URL shortener works on a simple principle: it creates a mapping between a short code and a long URL. When someone visits the short URL, the server looks up the original URL from the database and redirects the user to the destination.
The Flow
- User submits a long URL - The user provides a URL like
https://example.com/very/long/path/to/some/page - Server generates a short code - The server creates a unique identifier like
abc123 - Mapping is stored - The mapping
abc123 → https://example.com/...is saved in the database - Short URL is returned - The user gets a URL like
https://yoursite.com/abc123 - Redirect happens - When someone visits the short URL, the server looks up the original and performs a redirect
We use HTTP 301 (Moved Permanently) redirects because they tell browsers and search engines that the short URL has permanently moved to the long URL. This also ensures SEO value is passed to the destination URL.
Project Setup
Let's set up our project. We'll use Node.js with Express for the backend and SQLite for simplicity.
Creating the Project
First, create a new folder for your project and initialize it:
mkdir url-shortener
cd url-shortener
npm init -y
Installing Dependencies
Now let's install the packages we need:
npm install express sqlite3 shortid
Let's understand what each package does:
- express - The web framework for building our API and serving the frontend
- sqlite3 - A lightweight database that stores data in a simple file
- shortid - A library for generating unique, URL-friendly short codes
Project Structure
Create the following file structure:
url-shortener/
├── public/
│ └── index.html
├── server.js
└── database.js
Backend Logic
Let's start building the server. We'll create the main server file that handles all the API endpoints.
const express = require('express');
const shortid = require('shortid');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// API: Create a short URL
app.post('/api/shorten', (req, res) => {
const { longUrl } = req.body;
// Validate URL
if (!longUrl) {
return res.status(400).json({ error: 'URL is required' });
}
// Validate URL format
try {
new URL(longUrl);
} catch (e) {
return res.status(400).json({ error: 'Invalid URL format' });
}
// Generate short code
const shortCode = shortid.generate();
// Save to database
db.run(
'INSERT INTO urls (long_url, short_code, clicks, created_at) VALUES (?, ?, 0, datetime("now"))',
[longUrl, shortCode],
function(err) {
if (err) {
return res.status(500).json({ error: 'Database error' });
}
res.json({
shortUrl: `${req.protocol://${req.get('host')}/${shortCode}`,
shortCode: shortCode,
longUrl: longUrl
});
}
);
});
// API: Get all URLs
app.get('/api/urls', (req, res) => {
db.all('SELECT * FROM urls ORDER BY created_at DESC', [], (err, rows) => {
if (err) {
return res.status(500).json({ error: 'Database error' });
}
res.json(rows);
});
});
// Redirect: Handle short URLs
app.get('/:shortCode', (req, res) => {
const { shortCode } = req.params;
db.get(
'SELECT long_url FROM urls WHERE short_code = ?',
[shortCode],
(err, row) => {
if (err || !row) {
return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
}
// Increment click count
db.run('UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?', [shortCode]);
// Redirect with 301 status
res.redirect(301, row.long_url);
}
);
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
We need to add the path module at the top of the file. Update your require statements:
const express = require('express');
const path = require('path');
const shortid = require('shortid');
const db = require('./database');
Database Design
We'll use SQLite for our database because it's simple to set up and requires no separate database server. The data will be stored in a single file.
Database Schema
Our database needs a simple table to store URL mappings. Here's what we'll store:
- id - Auto-incrementing primary key
- long_url - The original URL that the user wants to shorten
- short_code - The unique short code (e.g., "abc123")
- clicks - Count of how many times the short URL has been visited
- created_at - Timestamp of when the URL was created
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
// Create database file in project directory
const dbPath = path.resolve(__dirname, 'urls.db');
const db = new sqlite3.Database(dbPath);
// Initialize database with schema
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
long_url TEXT NOT NULL,
short_code TEXT NOT NULL UNIQUE,
clicks INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create index for faster lookups
db.run(`
CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code)
`);
console.log('Database initialized successfully');
});
module.exports = db;
We created an index on the short_code column. This makes lookups extremely fast even with millions of URLs in the database. Without an index, the database would have to scan every row to find a match!
Generating Short Links
The heart of any URL shortener is the algorithm that generates unique short codes. Let's explore different approaches and then see how our library handles it.
Understanding shortid
We're using the shortid library because it generates:
- Short, URL-friendly strings
- Unique IDs that won't collide
- Non-sequential codes (for security - users can't guess other URLs)
How It Works
The shortid library uses a combination of timestamp and random values to generate unique 8-12 character codes. Let's test it:
// Test shortid generation
const shortid = require('shortid');
// Generate 5 unique IDs
for (let i = 0; i < 5; i++) {
console.log(shortid.generate());
}
// Output examples:
// 4j7g8k9l
// mno2p3qr
// stu5vw6x
// yz7ab8c9
// de1fg2hi
Custom Encoding (Optional Enhancement)
If you want shorter URLs, you could use a custom base-62 encoder:
// Base 62 encoder for shorter codes
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function encode(num) {
if (num === 0) return CHARSET[0];
let arr = [];
let base = CHARSET.length;
while (num > 0) {
arr.push(CHARSET[num % base]);
num = Math.floor(num / base);
}
return arr.reverse().join('');
}
// Example: encode sequential IDs
// encode(0) -> "0"
// encode(62) -> "10"
// encode(100) -> "1C"
Redirect Logic
The redirect functionality is what makes a URL shortener work. When a user visits a short URL, the server needs to find the original URL and send the user there.
The Redirect Endpoint
In our server.js, we have this route that handles all short URLs:
// Handle short URL redirects
app.get('/:shortCode', (req, res) => {
const { shortCode } = req.params;
// Look up the original URL
db.get(
'SELECT long_url FROM urls WHERE short_code = ?',
[shortCode],
(err, row) => {
// Handle errors or missing URLs
if (err || !row) {
console.log(`Short code not found: ${shortCode}`);
return res.status(404.send('URL not found'));
}
// Track clicks for analytics
db.run(
'UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?',
[shortCode]
});
// Perform 301 redirect (permanent redirect)
console.log(`Redirecting ${shortCode} -> ${row.long_url}`);
res.redirect(301, row.long_url);
}
);
});
HTTP Redirect Codes
We use HTTP 301 (Moved Permanently) for several reasons:
- 301 - Permanent redirect. Browsers cache this, making subsequent visits faster. Search engines transfer "link juice" to the destination.
- 302 - Temporary redirect. Would work but doesn't pass SEO benefits.
- 307 - Temporary redirect that preserves the HTTP method.
We increment the click counter before redirecting. This gives us basic analytics - you can see how many times each shortened URL has been visited. Later, you could expand this to track unique visitors, referrers, and more!
Creating a Simple Frontend
Now let's build a simple but clean web interface for our URL shortener. We'll create an HTML page that allows users to enter URLs and see their shortened versions.
<!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: 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 2rem;
}
.card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
input {
flex: 1;
padding: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
padding: 1rem 2rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #5568d3;
}
.result {
background: #f0f4ff;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
display: none;
}
.result.show {
display: block;
}
.result p {
margin-bottom: 0.5rem;
color: #666;
}
.result a {
color: #667eea;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid #e0e0e0;
}
th {
color: #666;
font-weight: 600;
}
.short-link {
color: #667eea;
text-decoration: none;
}
.error {
color: #e74c3c;
margin-bottom: 1rem;
display: none;
}
.error.show {
display: block;
}
</style>
</head>
<body>
<div class="container">
<h1>🔗 URL Shortener</h1>
<div class="card">
<div class="error" id="error"></div>
<div class="result" id="result">
<p>Your shortened URL:</p>
<a href="" id="short-url" target="_blank"></a>
</div>
<div class="input-group">
<input type="url" id="long-url" placeholder="Enter a long URL..." required>
<button id="shorten-btn">Shorten</button>
</div>
<table>
<thead>
<tr>
<th>Short URL</th>
<th>Original URL</th>
<th>Clicks</th>
</tr>
</thead>
<tbody id="url-table">
</tbody>
</table>
</div>
</div>
<script>
const longUrlInput = document.getElementById('long-url');
const shortenBtn = document.getElementById('shorten-btn');
const resultDiv = document.getElementById('result');
const shortUrlLink = document.getElementById('short-url');
const urlTable = document.getElementById('url-table');
const errorDiv = document.getElementById('error');
// Load existing URLs
async function loadUrls() {
const res = await fetch('/api/urls');
const urls = await res.json();
urlTable.innerHTML = urls.map(url => <?>`
<tr>
<td><a href="/${url.short_code}" class="short-link" target="_blank">/${url.short_code}</a></td>
<td title="${url.long_url}">${url.long_url.substring(0, 40)}...</td>
<td>${url.clicks}</td>
</tr>
`).join('');
}
// Shorten URL
async function shortenUrl() {
const longUrl = longUrlInput.value.trim();
if (!longUrl) return;
try {
const res = await fetch('/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ longUrl })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error creating short URL');
shortUrlLink.href = data.shortUrl;
shortUrlLink.textContent = data.shortUrl;
resultDiv.classList.add('show');
errorDiv.classList.remove('show');
longUrlInput.value = '';
loadUrls();
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.add('show');
resultDiv.classList.remove('show');
}
}
shortenBtn.addEventListener('click', shortenUrl);
longUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') shortenUrl();
});
loadUrls();
</script>
</body>
</html>
Testing the Application
Now let's test our URL shortener to make sure everything works correctly.
Starting the Server
Run the server with Node.js:
node server.js
You should see:
Database initialized successfully
Server running on http://localhost:3000
Testing with the Browser
- Open your browser and navigate to
http://localhost:3000 - You should see the URL shortener interface
- Enter a long URL (e.g.,
https://www.google.com/search?q=tutorials) - Click the "Shorten" button
- A shortened URL should appear (e.g.,
http://localhost:3000/V1sT7s) - Click the shortened URL to verify it redirects to the original
Testing the API Directly
You can also test the API using curl or a tool like Postman:
# Create a short URL
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"longUrl": "https://github.com"}'
# Response:
# {"shortUrl":"http://localhost:3000/abc123","shortCode":"abc123","longUrl":"https://github.com"}
# Get all URLs
curl http://localhost:3000/api/urls
# Test redirect
curl -I http://localhost:3000/abc123
- ✓ Can create a new short URL
- ✓ Short URL redirects to original
- ✓ Click counter increments on redirect
- ✓ Invalid URLs show error messages
- ✓ All existing URLs are listed
Deployment Overview
When you're ready to deploy your URL shortener to production, here's what you need to know.
Platform Options
- Render - Free tier available, easy Node.js deployment
- Railway - Simple deployment with SQLite support
- Heroku - Classic choice, requires paid tier for SQLite
- Vercel/Netlify - Great for frontend, requires adapter for Express
Production Considerations
Database
For production, consider upgrading from SQLite to a more robust database:
- PostgreSQL - Full-featured, free option on Render/Railway
- MongoDB - Good for document storage
- Redis - Ultra-fast for URL lookups
Security
// Add URL validation to prevent malicious redirects
function isValidUrl(string) {
try {
const url = new URL(string);
// Only allow http and https
return url.protocol === 'http:' || url.protocol === 'https:';
} catch(_)) {
return false;
}
}
// Use in your route handler
if (!isValidUrl(longUrl)) {
return res.status(400).json({ error: 'Invalid URL' });
}
Analytics Expansion
You could expand your application with:
- User accounts and authentication
- Custom short codes (e.g., mysite.com/github)
- Expiration dates for links
- Geographic analytics
- QR code generation
Now that you have a working URL shortener, try these challenges:
- Add user authentication
- Implement custom short codes
- Add a QR code generator
- Create an admin dashboard
Conclusion
Congratulations! You've successfully built a complete URL shortener from scratch. Let's review what you accomplished:
- Backend - Created an Express.js server with RESTful API endpoints
- Database - Set up SQLite with proper schema and indexes
- URL Shortening - Implemented short code generation using shortid
- Redirect Logic - Built 301 redirects with click tracking
- Frontend - Created a clean, functional user interface
This project gives you a solid foundation for understanding how web applications work. The concepts you've learned here apply to many other types of applications - from e-commerce sites to social networks.