Building Shortly: A Modern URL Shortener with Node.js, Express, and MongoDB
In today's digital landscape, URL shorteners have become an essential tool for sharing links efficiently across platforms with character limitations. Whether you're sharing links on social media, in email campaigns, or simply wanting cleaner, more manageable URLs, a URL shortener provides a valuable service.
This tutorial will guide you through building your own URL shortener service from scratch using Node.js, Express, and MongoDB. By the end, you'll have a fully functional application that you can host on your own server, customize to your needs, and even extend with additional features.
Table of Contents
<a id="introduction"></a>
1. Introduction and Project Overview
What We're Building
Shortly is a URL shortening service that takes long URLs and creates shortened, easy-to-share links. When users access these shortened links, they are redirected to the original destination. Our application will include:
A simple, clean user interface
Backend API with Express.js
MongoDB database for storing URLs
Custom shortening algorithm
Basic analytics to track link usage
Why Build a URL Shortener?
URL shorteners are an excellent learning project because they:
Demonstrate practical backend and frontend integration
Require thoughtful algorithm design
Provide opportunities to work with databases
Showcase API development
Offer a complete user flow from input to output
According to TechCrunch and The Verge, URL shorteners remain one of the most commonly used web utilities, with billions of shortened links generated annually.
Prerequisites
This tutorial assumes you have:
Basic knowledge of JavaScript
Familiarity with Node.js concepts
Understanding of HTTP and RESTful APIs
Basic command line skills
Node.js and npm installed on your system
<a id="setup"></a>
2. Setting Up Your Development Environment
Installing Required Software
First, ensure you have Node.js installed. You can download it from nodejs.org.
Next, we'll need MongoDB. You have two options:
Install MongoDB locally
Use MongoDB Atlas (cloud-based solution)
For simplicity, we'll use MongoDB Atlas for this tutorial, as it doesn't require local installation and configuration.
Project Initialization
Create a new directory for your project:
bashmkdir shortlycd shortly
Initialize a new Node.js project:
bashnpm init -y
Installing Dependencies
We'll need several packages for our project:
bashnpm install express mongoose shortid valid-url cors dotenvnpm install --save-dev nodemon
Here's what each package does:
express
: Our web server framework
mongoose
: MongoDB object modeling tool
shortid
: Generates short, unique, non-sequential ids
valid-url
: URL validation
cors
: Enables cross-origin resource sharing
dotenv
: Loads environment variables from a .env file
nodemon
: Development tool that automatically restarts the server
Project Structure
Let's set up a clean project structure:
shortly/│ ├── config/ │ └── db.js # Database connection configuration │ ├── models/ │ └── Url.js # Mongoose model for URLs │ ├── routes/ │ └── urls.js # Express routes for URL operations │ ├── public/ │ ├── css/ │ │ └── style.css # Stylesheet │ └── js/ │ └── app.js # Frontend JavaScript │ ├── views/ │ └── index.html # Main HTML page │ ├── .env # Environment variables ├── server.js # Main application file └── package.json # Project metadata and dependencies
Create these directories and files:
bashmkdir config models routes public public/css public/js viewstouch config/db.js models/Url.js routes/urls.js public/css/style.css public/js/app.js views/index.html server.js .env
<a id="architecture"></a>
3. Project Architecture and Structure
Architectural Overview
Our URL shortener follows a classic three-tier architecture:
Presentation Layer
: HTML/CSS/JavaScript frontend
Application Layer
: Express.js backend with RESTful API
Data Layer
: MongoDB database
The application flow works like this:
User enters a long URL in the frontend
Frontend sends the URL to our API
Backend validates the URL
If valid, the URL is stored in the database and a short code is generated
The short URL is returned to the frontend
When someone visits the short URL, the server looks up the original URL and redirects
Design Decisions
For our shortening algorithm, we'll use the shortid
package to generate a unique, short code. While we could implement a custom Base62 encoding system (using a-z, A-Z, 0-9), shortid
provides a reliable, collision-resistant solution out of the box.
According to discussions on Hacker News and Stack Overflow Blog, most production URL shorteners use either random string generation or deterministic encoding of database IDs.
<a id="backend"></a>
4. Backend Development with Express
Setting Up the Express Server
Let's start by creating our main server file. Open server.js
and add:
javascriptconst express = require('express');const connectDB = require('./config/db');const cors = require('cors');const path = require('path');require('dotenv').config();
const app = express();
// Connect to database
connectDB();
// Middleware
app.use(express.json({ extended: false }));app.use(cors());
// Serve static files
app.use(express.static(path.join(__dirname, 'public')));
// Define routes
app.use('/api/url', require('./routes/urls'));
// Serve main page
app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'views', 'index.html'));});
// Redirect route for shortened URLs
app.get('/:code', async (req, res) => { try { const url = await require('./models/Url').findOne({ urlCode: req.params.code });
if (url) {
// Increment clicks
url.clicks++; await url.save();
return res.redirect(url.longUrl); } else { return res.status(404).json('No URL found'); } } catch (err) { console.error(err); res.status(500).json('Server error'); }});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Database Configuration
Now let's set up our database connection. Open config/db.js
:
javascriptconst mongoose = require('mongoose');require('dotenv').config();
const connectDB = async () => { try { await mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true, }); console.log('MongoDB Connected...'); } catch (err) { console.error(err.message); process.exit(1); }};
module.exports = connectDB;
Create a .env
file in your project root:
MONGO_URI=your_mongodb_connection_stringBASE_URL=http://localhost:5000
PORT=5000
You'll need to replace your_mongodb_connection_string
with your actual MongoDB connection string from MongoDB Atlas or your local MongoDB installation.
<a id="database"></a>
5. Database Design with MongoDB
Creating the URL Schema
For our URL shortener, we need to store several pieces of information:
The original (long) URL
The generated short code
The full short URL
Creation date
Click count for analytics
Let's create our Mongoose model. Open models/Url.js
:
javascriptconst mongoose = require('mongoose'); const urlSchema = new mongoose.Schema({ urlCode: String, longUrl: String, shortUrl: String, date: { type: String, default: Date.now }, clicks: { type: Number, default: 0 }}); module.exports = mongoose.model('Url', urlSchema);
This schema provides a structure for our URL documents in MongoDB. The clicks
field will be used for our basic analytics feature.
According to best practices discussed on Dev.to and CSS-Tricks, tracking click counts is an essential feature for any URL shortener service.
<a id="algorithm"></a>
6. The URL Shortening Algorithm
Generating Short Codes
For our URL shortener, we need to generate short, unique codes that we can use to identify the original URLs. We'll use the shortid
package for this purpose:
javascriptconst shortid = require('shortid');
// Generate a short codeconst code = shortid.generate();
The shortid
package generates short, non-sequential, URL-friendly unique IDs. By default, it uses a 7-10 character string composed of letters and numbers.
If you wanted to implement your own algorithm instead, you could use a Base62 encoding (a-z, A-Z, 0-9) of incremental IDs from your database. This would look something like:
javascriptfunction toBase62(num) { const characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const base = characters.length; let encoded = '';
if (num === 0) { return characters[0]; }
while (num > 0) { encoded = characters[num % base] + encoded; num = Math.floor(num / base); }
return encoded;}
However, using shortid
is simpler and provides good guarantees against collisions.
<a id="api"></a>
7. API Endpoint Implementation
URL Routes
Let's implement our API endpoints for creating and retrieving shortened URLs. Open routes/urls.js
:
javascriptconst express = require('express');const router = express.Router();const validUrl = require('valid-url');const shortid = require('shortid');require('dotenv').config();
const Url = require('../models/Url');
// @route POST /api/url/shorten// @desc Create short URL
router.post('/shorten', async (req, res) => { const { longUrl } = req.body; const baseUrl = process.env.BASE_URL;
// Check base url
if (!validUrl.isUri(baseUrl)) { return res.status(401).json('Invalid base URL'); }
// Create URL code
const urlCode = shortid.generate();
// Check long url
if (validUrl.isUri(longUrl)) { try {
// Check if the URL already exists in the database
let url = await Url.findOne({ longUrl });
if (url) { return res.json(url); } else {
// Create the short URL
const shortUrl = `${baseUrl}/${urlCode}`;
// Create new URL object
url = new Url({ longUrl, shortUrl, urlCode, date: new Date() });
// Save to database
await url.save();
return res.json(url); } } catch (err) { console.error(err); return res.status(500).json('Server error'); } } else { return res.status(401).json('Invalid long URL'); }});
// @route GET /api/url/:code// @desc Redirect to long URL
router.get('/:code', async (req, res) => { try { const url = await Url.findOne({ urlCode: req.params.code });
if (url) { return res.json(url); } else { return res.status(404).json('No URL found'); } } catch (err) { console.error(err); res.status(500).json('Server error'); }});
module.exports = router;
This sets up two main endpoints:
POST /api/url/shorten
- Creates a shortened URL
GET /api/url/:code
- Retrieves information about a shortened URL
<a id="frontend"></a>
8. Building the Frontend Interface
HTML Structure
Let's create a simple, clean interface for our URL shortener. Open views/index.html
:
<html><!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Shortly - URL Shortener</title> <link rel="stylesheet" href="/css/style.css"></head><body> <div class="container"> <header> <h1>Shortly</h1> <p>A simple URL shortener built with Node.js, Express, and MongoDB</p> </header>
<main> <form id="url-form"> <div class="input-group"> <input type="url" id="long-url" placeholder="Paste your long URL here" required > <button type="submit">Shorten</button> </div> </form>
<div id="result" class="hidden"> <h2>Your Shortened URL</h2> <div class="short-url-container"> <input type="text" id="short-url" readonly> <button id="copy-btn">Copy</button> </div> <div class="original-url"> <p>Original URL: <span id="original-url"></span></p> </div> </div>
<div id="error" class="hidden"> <p>There was an error shortening your URL. Please try again.</p> </div> </main>
<footer> <p>© 2025 Shortly. View the <a href="https://github.com/yourusername/shortly" target="_blank">source code</a>.</p> </footer> </div>
<script src="/js/app.js"></script></body></html>
CSS Styling
Now let's add some styling. Open public/css/style.css
:
css:root { --primary-color: #3498db; --secondary-color: #2980b9; --dark-color: #2c3e50; --light-color: #ecf0f1; --success-color: #2ecc71; --error-color: #e74c3c;}
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; background: #f4f7f9;}
.container { max-width: 800px; margin: 0 auto; padding: 2rem;}
header { text-align: center; margin-bottom: 2rem;}
h1 { color: var(--primary-color); margin-bottom: 0.5rem; font-size: 2.5rem;}
header p { color: var(--dark-color);}
.input-group { display: flex; margin-bottom: 1.5rem;}
input[type="url"] { flex: 1; padding: 0.8rem; border: 1px solid #ddd; border-radius: 4px 0 0 4px; font-size: 1rem;}
button { background: var(--primary-color); color: white; border: none; padding: 0 1.5rem; border-radius: 0 4px 4px 0; cursor: pointer; font-size: 1rem; transition: background-color 0.3s;}
button:hover { background: var(--secondary-color);}
#result { background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); margin-bottom: 2rem;}
#result h2 { color: var(--dark-color); margin-bottom: 1rem; font-size: 1.5rem;}
.short-url-container { display: flex; margin-bottom: 1rem;}
#short-url { flex: 1; padding: 0.8rem; border: 1px solid #ddd; border-radius: 4px 0 0 4px; font-size: 1rem; background: var(--light-color);}
#copy-btn { border-radius: 0 4px 4px 0;}
.original-url { font-size: 0.9rem; color: #777;}
#original-url { word-break: break-all;}
#error { background: var(--error-color); color: white; padding: 1rem; border-radius: 4px; margin-bottom: 2rem;}
.hidden { display: none;}
footer { text-align: center; margin-top: 2rem; color: #777; font-size: 0.9rem;}
footer a { color: var(--primary-color); text-decoration: none;}
footer a:hover { text-decoration: underline;}
@media (max-width: 600px) { .container { padding: 1rem; }
h1 { font-size: 2rem; }}
JavaScript Frontend Logic
Now we'll implement the frontend logic to interact with our API. Open public/js/app.js
:
javascriptdocument.addEventListener('DOMContentLoaded', () => { const urlForm = document.getElementById('url-form'); const longUrlInput = document.getElementById('long-url'); const resultContainer = document.getElementById('result'); const shortUrlDisplay = document.getElementById('short-url'); const originalUrlDisplay = document.getElementById('original-url'); const copyBtn = document.getElementById('copy-btn'); const errorContainer = document.getElementById('error');
// Form submission
urlForm.addEventListener('submit', async (e) => { e.preventDefault();
const longUrl = longUrlInput.value;
if (!longUrl) { showError('Please enter a URL to shorten'); return; }
try { const response = await fetch('/api/url/shorten', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ longUrl }), });
if (!response.ok) { const errorData = await response.json(); throw new Error(errorData || 'Something went wrong'); }
const data = await response.json();
// Display the result
showResult(data.shortUrl, data.longUrl);
// Reset the form
longUrlInput.value = ''; } catch (err) { showError(err.message); } });
// Copy button functionality
copyBtn.addEventListener('click', () => { shortUrlDisplay.select(); document.execCommand('copy');
// Visual feedback
const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; copyBtn.style.backgroundColor = '#2ecc71';
setTimeout(() => { copyBtn.textContent = originalText; copyBtn.style.backgroundColor = ''; }, 2000); });
// Helper functions
function showResult(shortUrl, originalUrl) {
// Hide error if it's showing
errorContainer.classList.add('hidden');
// Set the values
shortUrlDisplay.value = shortUrl; originalUrlDisplay.textContent = originalUrl;
// Show the result container
resultContainer.classList.remove('hidden'); }
function showError(message) { errorContainer.textContent = message; errorContainer.classList.remove('hidden'); resultContainer.classList.add('hidden'); }});
<a id="analytics"></a>
9. Implementing Basic Analytics
Tracking Click Counts
We've already set up a basic analytics feature by adding a clicks
field to our URL model. Now, let's create a simple endpoint to retrieve these analytics.
Add this to your routes/urls.js
file:
javascript
// @route GET /api/url/stats/:code// @desc Get URL stats
router.get('/stats/:code', async (req, res) => { try { const url = await Url.findOne({ urlCode: req.params.code });
if (url) { return res.json({ urlCode: url.urlCode, longUrl: url.longUrl, shortUrl: url.shortUrl, clicks: url.clicks, date: url.date }); } else { return res.status(404).json('No URL found'); } } catch (err) { console.error(err); res.status(500).json('Server error'); }});
Adding Analytics to the Frontend
Let's add a simple analytics button to our frontend. Add this to your views/index.html
file, inside the #result
div:
html<div class="analytics"> <button id="stats-btn">View Stats</button> <div id="stats-display" class="hidden"> <p>Clicks: <span id="click-count">0</span></p> <p>Created: <span id="created-date"></span></p> </div></div>
Add this CSS to your style.css
:
css.analytics { margin-top: 1rem; border-top: 1px solid #eee; padding-top: 1rem;} #stats-btn { background: var(--dark-color); border-radius: 4px; padding: 0.5rem 1rem; font-size: 0.9rem;} #stats-btn:hover { background: #34495e;} #stats-display { margin-top: 1rem; font-size: 0.9rem; color: #777;}
Add this JavaScript to your app.js
:
javascript
// Stats button functionality
const statsBtn = document.getElementById('stats-btn');const statsDisplay = document.getElementById('stats-display');const clickCount = document.getElementById('click-count');const createdDate = document.getElementById('created-date');
statsBtn.addEventListener('click', async () => { try {
// Get the URL code from the shortened URL
const shortUrl = shortUrlDisplay.value; const urlCode = shortUrl.split('/').pop();
const response = await fetch(`/api/url/stats/${urlCode}`);
if (!response.ok) { throw new Error('Could not fetch stats'); }
const data = await response.json();
// Update the stats display
clickCount.textContent = data.clicks; createdDate.textContent = new Date(data.date).toLocaleString();
// Show the stats display
statsDisplay.classList.remove('hidden'); } catch (err) { showError(err.message); }});
According to VentureBeat, providing even basic analytics can significantly increase the value of a URL shortener service to its users.
<a id="testing"></a>
10. Testing Your Application
Manual Testing
Now that we've built our application, let's test it:
Start your server:
bashnpm run dev
Open your browser and navigate to
http://localhost:5000
Enter a long URL in the input field and click "Shorten"
Copy the short URL and open it in a new tab to verify redirection
Click "View Stats" to see the click count and creation date
Automated Testing
For a more robust application, you might want to add automated tests. Here's how you could set up some basic tests using Jest and Supertest.
First, install the testing dependencies:
bashnpm install --save-dev jest supertest
Then, create a tests
directory and add a test file:
bashmkdir teststouch tests/url.test.js
Add this to url.test.js
:
javascriptconst request = require('supertest');const mongoose = require('mongoose');const { MongoMemoryServer } = require('mongodb-memory-server');const app = require('../server');
// Make sure to export your Express app from server.js
let mongoServer;
beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); await mongoose.connect(mongoUri);});
afterAll(async () => { await mongoose.disconnect(); await mongoServer.stop();});
describe('URL Shortener API', () => { test('POST /api/url/shorten with valid URL', async () => { const response = await request(app) .post('/api/url/shorten') .send({ longUrl: 'https://www.example.com' });
expect(response.status).toBe(200); expect(response.body).toHaveProperty('shortUrl'); expect(response.body).toHaveProperty('longUrl', 'https://www.example.com'); });
test('POST /api/url/shorten with invalid URL', async () => { const response = await request(app) .post('/api/url/shorten') .send({ longUrl: 'invalidurl' });
expect(response.status).toBe(401); });});
Add a test script to your package.json
:
json"scripts": { "test": "jest"}
Run the tests:
bashnpm test
<a id="deployment"></a>
11. Deployment Options
Hosting Options
When you're ready to deploy your URL shortener, you have several options:
Heroku
Easy to set up and deploy
Free tier available for hobby projects
Supports Node.js applications
Vercel or Netlify
Great for frontend hosting
Can be combined with serverless functions
DigitalOcean or AWS
More control over your environment
More scalable but more complex to set up
Railway or Render
Modern platforms with simple deploys
Good balance of control and ease of use
Deploying to Heroku
Let's walk through deploying to Heroku:
Install the Heroku CLI
Login to Heroku:
bashheroku login
Create a
Procfile
in your project root:
web: node server.js
Initialize a Git repository (if you haven't already):
bashgit initgit add .git commit -m "Initial commit"
Create a Heroku app:
bashheroku create shortly-app-name
Set up environment variables:
bashheroku config:set MONGO_URI=your_production_mongodb_uriheroku config:set BASE_URL=https://your-app-name.herokuapp.com
Push to Heroku:
bashgit push heroku master
<a id="security"></a>
12. Security Considerations
Protecting Your URL Shortener
URL shorteners can be targets for abuse, so it's important to implement security measures:
Rate Limiting
Limit the number of URLs that can be created per IP address
Use a package like
express-rate-limit
javascriptconst rateLimit = require('express-rate-limit');
const limiter = rateLimit({ windowMs: 15 * 60 * 1000,
// 15 minutes
max: 100
// limit each IP to 100 requests per windowMs
});
app.use('/api/url', limiter);
URL Validation
We're already using
valid-url
to validate URLs
Consider additional checks for malicious URLs
HTTPS
Ensure your production deployment uses HTTPS
Redirect HTTP requests to HTTPS
Preview Page
Consider adding a preview page before redirecting
This can protect users from malicious redirects
<a id="enhancements"></a>
13. Future Enhancements
Taking Your URL Shortener Further
Once you have the basic URL shortener working, here are some features you could add:
Custom Short Codes
Allow users to choose their own short codes
Validate to ensure no duplicates
User Accounts
Add authentication
Allow users to manage their shortened URLs
Advanced Analytics
Track referrers, geographical data, browser info
Provide visualizations of this data
QR Code Generation
Generate QR codes for shortened URLs
Useful for print materials and physical displays
Link Expiration
Set expiration dates for links
Automatically remove expired links
Password Protection
Allow links to be protected with passwords
Require a password before redirecting
API Keys
Provide an API for programmatic URL shortening
Issue API keys to control access
<a id="conclusion"></a>
14. Conclusion
Congratulations! You've built a complete URL shortener application using Node.js, Express, and MongoDB. This project demonstrates many important web development concepts:
RESTful API design
Database modeling
Frontend/backend integration
Form handling and validation
Error handling
Basic analytics
Deployment considerations
The skills you've learned here can be applied to many other web development projects. As mentioned in the Future Enhancements section, there are many ways you could extend this project to add more features and functionality.
Remember that URL shorteners, while seemingly simple, are a critical part of the modern web infrastructure. According to MIT Technology Review, billions of shortened URLs are created and clicked every day, serving a crucial role in social media, marketing, and general web usability.
I hope you've enjoyed building this project and that it serves as a valuable addition to your portfolio!