We use services like Bit.ly and TinyURL every day. But how do they actually work? The answer is surprisingly simple: take a URL, generate a short ID, map them together, redirect.
In this guide, you'll build your own URL shortener from scratch. No magic libraries, no boilerplate frameworks — just Node.js, Express, and a bit of logic.
What you'll learn:
- Setting up an HTTP server with Express
- Generating short, unique IDs with nanoid
- Storing URLs with an in-memory data structure
- Creating a POST endpoint to shorten URLs
- Building a GET endpoint for 301 redirects
- Adding a stats endpoint to track click counts
- Implementing input validation and security measures
Step 1: Project Setup
Open your terminal and create a new directory:
mkdir url-shortener && cd url-shortener
npm init -y
npm install express nanoid@3
We're using nanoid@3 because v3 supports CommonJS (require). v4 and above are ESM-only (import) — we'll keep things simple with CommonJS for this guide.
After installation, your package.json should look like this:
{
"name": "url-shortener",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "^4.21.0",
"nanoid": "^3.3.8"
}
}
node --watch is a built-in file watcher available in Node.js 18+. It automatically restarts the server when files change — no need for nodemon.
Step 2: Express Server
Create an index.js file:
const express = require('express');
const { nanoid } = require('nanoid');
const app = express();
const PORT = process.env.PORT || 3000;
// JSON body parsing
app.use(express.json());
// In-memory store
const urlStore = new Map();
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', urlCount: urlStore.size });
});
app.listen(PORT, () => {
console.log(`URL Shortener running at: http://localhost:${PORT}`);
});
Test it:
npm start
# In another terminal:
curl http://localhost:3000/health
Response: {"status":"ok","urlCount":0}
That's it. You have a working HTTP server. Now let's add the real functionality.
Step 3: Short ID Generation with nanoid
The heart of a URL shortener is generating a short, unique identifier to represent the original URL. That's where nanoid comes in.
const { nanoid } = require('nanoid');
// Default: 21 characters
console.log(nanoid()); // "V1StGXR8_Z5jdHi6B-myT"
// Short version: 8 characters
console.log(nanoid(8)); // "xQ3fK9pL"
Why nanoid instead of UUID?
UUID generates 36 characters:
550e8400-e29b-41d4-a716-446655440000. For a URL shortener, that's absurdly long. nanoid does the same job in 8 characters, uses a URL-safe character set (A-Za-z0-9_-), and is 60% smaller than UUID. Collision probability? With an 8-character nanoid generating 1,000 IDs per second, you'd need roughly 5,500 years to reach a 1% collision chance.
Step 4: In-Memory Store
Before adding database complexity, we'll use JavaScript's Map structure. Map provides a cleaner API and better performance than plain objects for key-value pairs.
const urlStore = new Map();
// Data structure: shortId -> { originalUrl, createdAt, clicks }
Each URL record stores three pieces of data:
originalUrl— The long URL the user submittedcreatedAt— Creation timestamp (for analytics)clicks— Click counter (starts at 0)
This setup won't survive a server restart (data lives in memory only), but it's perfect for understanding the concept. We'll cover persistent storage alternatives at the end.
Step 5: POST Endpoint — Shorten a URL
The first real endpoint. The user sends a long URL, we return a short link:
app.post('/api/shorten', (req, res) => {
const { url } = req.body;
// Validation: Is URL present?
if (!url) {
return res.status(400).json({ error: 'url field is required' });
}
// Validation: Is it a valid URL?
try {
new URL(url);
} catch {
return res.status(400).json({ error: 'Enter a valid URL (must start with https://...)' });
}
// Generate short ID
const shortId = nanoid(8);
// Store it
urlStore.set(shortId, {
originalUrl: url,
createdAt: new Date().toISOString(),
clicks: 0,
});
// Response
const shortUrl = `${req.protocol}://${req.get('host')}/${shortId}`;
res.status(201).json({
shortId,
shortUrl,
originalUrl: url,
});
});
Test it:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide"}'
Response:
{
"shortId": "xQ3fK9pL",
"shortUrl": "http://localhost:3000/xQ3fK9pL",
"originalUrl": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide"
}
The new URL() constructor validates both format and protocol. Inputs like htp://wrong or just-text throw an error. No need for a separate regex.
Step 6: GET Endpoint — Redirect
We redirect incoming requests for shortened URLs to the original address:
app.get('/:shortId', (req, res) => {
const { shortId } = req.params;
const entry = urlStore.get(shortId);
if (!entry) {
return res.status(404).json({ error: 'URL not found' });
}
// Increment click counter
entry.clicks += 1;
// 301 permanent redirect
res.redirect(301, entry.originalUrl);
});
Test it:
# -L: follow redirects, -v: verbose output
curl -L -v http://localhost:3000/xQ3fK9pL
301 or 302?
301 (Moved Permanently): Browsers and search engines cache this redirect. On subsequent visits, they go directly to the target URL without asking your server. This transfers SEO value to the target page. Downside: click counters may undercount because browsers redirect from cache.
302 (Found / Temporary): The browser asks your server every time. Click counters always work accurately. No SEO value transfer.
For URL shorteners, 301 is preferred because the target URL typically doesn't change and it provides a performance advantage. If you need precise click analytics, use 302 instead.
Step 7: Stats Endpoint
We add a stats endpoint to track the performance of each shortened URL:
app.get('/api/stats/:shortId', (req, res) => {
const { shortId } = req.params;
const entry = urlStore.get(shortId);
if (!entry) {
return res.status(404).json({ error: 'URL not found' });
}
res.json({
shortId,
originalUrl: entry.originalUrl,
createdAt: entry.createdAt,
clicks: entry.clicks,
});
});
Test it:
curl http://localhost:3000/api/stats/xQ3fK9pL
Response:
{
"shortId": "xQ3fK9pL",
"originalUrl": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide",
"createdAt": "2026-03-17T10:30:00.000Z",
"clicks": 3
}
Important: Define the /api/stats/:shortId route before the /:shortId route. Express matches routes in order. If /:shortId comes first, Express will interpret stats as a shortId and return 404.
API Endpoint Reference
| Method | Path | Body | Response | Description |
|---|---|---|---|---|
| POST | /api/shorten | { "url": "https://..." } | { shortId, shortUrl, originalUrl } | Shorten a new URL |
| GET | /:shortId | — | 301 Redirect | Redirect to original URL |
| GET | /api/stats/:shortId | — | { shortId, originalUrl, createdAt, clicks } | Click statistics |
| GET | /health | — | { status, urlCount } | Server health check |
Bonus: Input Validation and Rate Limiting
For a production environment, you need two important security layers:
URL Format Validation
We already do basic validation with new URL(). Let's extend it:
function isValidUrl(input) {
try {
const parsed = new URL(input);
// Only accept http and https protocols
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
// Usage
if (!isValidUrl(url)) {
return res.status(400).json({ error: 'Only http/https URLs are accepted' });
}
This blocks dangerous or unwanted protocols like javascript:alert(1) or ftp://file.txt.
Rate Limiting
Prevent bad actors from spamming your API with a simple rate limiter:
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Try again in 15 minutes.' },
});
// Apply only to the shorten endpoint
app.use('/api/shorten', limiter);
Complete Working Code
Here's the full index.js combining everything — copy-paste ready:
const express = require('express');
const { nanoid } = require('nanoid');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
const urlStore = new Map();
// --- Helpers ---
function isValidUrl(input) {
try {
const parsed = new URL(input);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
// --- Routes ---
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', urlCount: urlStore.size });
});
// Shorten URL
app.post('/api/shorten', (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'url field is required' });
}
if (!isValidUrl(url)) {
return res.status(400).json({ error: 'Enter a valid http/https URL' });
}
const shortId = nanoid(8);
urlStore.set(shortId, {
originalUrl: url,
createdAt: new Date().toISOString(),
clicks: 0,
});
const shortUrl = `${req.protocol}://${req.get('host')}/${shortId}`;
res.status(201).json({ shortId, shortUrl, originalUrl: url });
});
// Stats
app.get('/api/stats/:shortId', (req, res) => {
const entry = urlStore.get(req.params.shortId);
if (!entry) {
return res.status(404).json({ error: 'URL not found' });
}
res.json({
shortId: req.params.shortId,
originalUrl: entry.originalUrl,
createdAt: entry.createdAt,
clicks: entry.clicks,
});
});
// Redirect
app.get('/:shortId', (req, res) => {
const entry = urlStore.get(req.params.shortId);
if (!entry) {
return res.status(404).json({ error: 'URL not found' });
}
entry.clicks += 1;
res.redirect(301, entry.originalUrl);
});
app.listen(PORT, () => {
console.log(`URL Shortener running at: http://localhost:${PORT}`);
});
Test Commands
Start the server and run these commands in sequence:
# 1. Shorten a URL
curl -s -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://nodejs.org/en/docs"}' | jq .
# 2. Test the redirect (replace with your shortId from above)
curl -I http://localhost:3000/YOUR_SHORT_ID
# 3. Check stats
curl -s http://localhost:3000/api/stats/YOUR_SHORT_ID | jq .
# 4. Try an invalid URL
curl -s -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "not-a-url"}' | jq .
Next Steps
The URL shortener in this guide is fully functional, but a few critical improvements are needed for production use:
-
Database integration — Replace the in-memory Map with Redis (for speed) or PostgreSQL/SQLite (for persistence). Data survives server restarts.
-
Custom slug support — Let users choose their own short names: add a
customSlugfield to the/api/shortenbody, check for uniqueness. -
QR code generation — Use the
qrcodenpm package to generate a QR code for each shortened URL. Invaluable for physical marketing materials. -
Analytics dashboard — Go beyond click counts: track referrer, country, device type, time-series charts. A simple Chart.js frontend does the job.
-
URL expiration — Add an
expiresAtfield. Ideal for temporary campaign links. -
Authentication — Add JWT or API key-based user management. Each user sees only their own links.
Want to go deeper with Node.js projects? See Build Your Own REST API for a comprehensive API development guide.
For scalable backend projects, explore our software consulting services.
Frequently Asked Questions
Can this project work without a database?
Yes — it works exactly as built in this guide. JavaScript's Map structure holds data in memory, and read/write operations happen in nanoseconds. The downside: all data resets when the server stops or restarts. This is fine for personal projects and prototyping. For production, add Redis or SQLite.
What are the security risks of a URL shortener?
The biggest risk is open redirect: an attacker shortens a malicious URL (like a phishing page) and distributes it as if it's harmless. To prevent this: (1) check shortened URLs against the Google Safe Browsing API, (2) show an interstitial page before redirecting ("You are being redirected to: ..."), (3) add a spam reporting mechanism. Also block javascript:, data:, and file: protocols — the isValidUrl function in this guide already handles that.
What should I use instead of in-memory store in production?
It depends on your use case. Redis: hundreds of thousands of reads/writes per second, TTL (automatic expiration) support, fastest option. PostgreSQL: complex queries, relational data, analytics reporting. SQLite: single server, low traffic, zero external dependencies. For most small-to-medium projects, a Redis + PostgreSQL combination works best: Redis handles fast redirects from cache, PostgreSQL provides persistent storage and analytics.
