Welcome back to Code in Action, the series where we build practical backend projects step by step.
In this tutorial, we're going to recreate a simplified version of Bitly—the popular online service that allows you to convert long URLs into shorter and easier to share ones.
Our API will include two endpoints:
- HTTP POST
/shorten
for storing the original URL and responding with a custom link containing a short ID. - HTTP GET
/:shortId
for redirecting the client to the original URL corresponding to the short ID.
By the end of this tutorial, you'll know how to:
- Validate and normalize user-provided URLs
- Generate short, unique IDs
- Store and retrieve data in an in-memory object
- Redirect clients from short links to original URLs
- Return appropriate HTTP status codes for errors and edge cases
- Protect your service by blacklisting malicious or sensitive addresses
Ready? Let's build!
Step 1: Set Up the Project
Let's create and enter a new directory named bitly-lite
.
mkdir bitly-lite
cd bitly-lite
Let's initialize the project, and install the express
package for implementing the API and the nanoid
package for generating secure URL-friendly unique string IDs
npm init
npm install express nanoid
Step 2: Create the Server
Within the root directory, let's create a new file for the API named server.js
.
touch server.js
And within it:
- Import the
express
package. - Create a new server instance by invoking the top-level function exported by the package.
- Bind the server to the development port 5000 using the
listen()
method of the server instance.
const express = require('express');
const server = express();
server.listen(5000, () => {
console.log('Server running on port 5000...');
});
Step 3: Create the Database Object
In order to keep this project simple, we'll use a global object literal named urls
as in-memory database.
let urls = {
<shortId>: <originalUrl>
};
Where:
-
<shortId>
is a unique string identifier for a shortened URL of 6 characters exactly generated using thenanoid
package. -
<originalUrl>
is the full-length original URL string.
For example:
let urls = {
'0si_Cq': 'https://learnbackend.dev',
'X_dCS1': 'https://learnbackend.dev/get-started'
};
So, let's declare a urls
variable right after the server instantiation.
const express = require('express');
const server = express();
let urls = {};
server.listen(5000, () => {
console.log('Server running on port 5000...');
});
It's important to note that this type of in-memory storage is not suitable for production as the data will be erased every time the server crashes or restarts.
You should consider persisting the data using either a JSON file for simplicity, a database like MySQL for extended capabilities, or a caching software like Redis for efficiency.
Step 4: Shorten URLs
Implement the endpoint
Let's declare a new HTTP POST /shorten
endpoint responsible for storing a URL and responding with a shortened one.
const express = require('express');
const server = express();
let urls = {};
server.post('/shorten', express.json(), (req, res) => {
//
});
// ...
As we expect the message body of the incoming HTTP requests to be in the JSON format, we'll use the express.json()
middleware to automatically convert it into usable JSON objects.
Extract the URL
The message body of the incoming HTTP request should contain an object with a single property named originalUrl
whose value is a valid URL. For example: { "originalUrl": "https://learnbackend.dev" }.
So, let's extract the originalUrl
property from the request's message body that contains the original URL to shorten, and respond with an HTTP 400 Bad Request
if it is null
or undefined
.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
});
Normalize the URL
At first glance, two URLs may look different but actually point to the same place. For example, https://learnbackend.dev/get-started
, learnbackend.dev//get-started/
are equivalent.
Normalization allows us to bring consistency to our API by trimming spaces, forcing lowercase domains, stripping unnecessary slashes, and so on, in order to avoid storing two identical URLs as separate entries, therefore wasting IDs and creating duplicates.
So, let's start by removing whitespace from both ends of the URL.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
});
Let's then prefix the URL with https://
if it doesn't start with a scheme (e.g., http://
, ftp://
, irc://
).
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
});
Let's initialize a new URL object within a try…catch
block using the built-in URL
class, and respond with an HTTP 400 Bad Request
if it throws an error during instantiation - which means that the URL format is invalid.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
try {
originalUrl = new URL(originalUrl);
} catch (error) {
return res.sendStatus(400);
}
});
Let's remove any duplicate and trailing slashes from the URL's pathname.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
try {
originalUrl = new URL(originalUrl);
} catch (error) {
return res.sendStatus(400);
}
originalUrl.pathname = originalUrl.pathname
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
});
Let's sort the URL's query string parameters in alphabetical order for consistency.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
try {
originalUrl = new URL(originalUrl);
} catch (error) {
return res.sendStatus(400);
}
originalUrl.pathname = originalUrl.pathname
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
originalUrl.searchParams.sort();
});
Let's remove the port number from the URL if it matches the standard HTTP and HTTPs ports.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
try {
originalUrl = new URL(originalUrl);
} catch (error) {
return res.sendStatus(400);
}
originalUrl.pathname = originalUrl.pathname
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
originalUrl.searchParams.sort();
if (
(originalUrl.protocol === 'http:' && originalUrl.port === '80') ||
(originalUrl.protocol === 'https:' && originalUrl.port === '443')
) {
originalUrl.port = '';
}
});
And finally, let's convert the URL object back into a string.
server.post('/shorten', express.json(), (req, res) => {
let originalUrl = req.body?.originalUrl;
if (!originalUrl) {
return res.sendStatus(400);
}
originalUrl = originalUrl.trim();
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(originalUrl)) {
originalUrl = 'https://' + originalUrl;
}
try {
originalUrl = new URL(originalUrl);
} catch (error) {
return res.sendStatus(400);
}
originalUrl.pathname = originalUrl.pathname
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
originalUrl.searchParams.sort();
if (
(originalUrl.protocol === 'http:' && originalUrl.port === '80') ||
(originalUrl.protocol === 'https:' && originalUrl.port === '443')
) {
originalUrl.port = '';
}
originalUrl = originalUrl.toString();
});
💡 New to backend programming in Node.js?
Check out the Learn Backend Mastery Program - a complete zero-to-hero roadmap that takes you from beginner to job-ready junior Node.js backend developer in 12 months.
Store and shorten the URL
Let's first import the nanoid
package at the top of the file for generating unique identifiers.
const express = require('express');
const { nanoid } = require('nanoid');
// ...
Then, in order to avoid unnecessarily storing duplicate URLs, let's implement a simple caching mechanism that iterates on each property of the urls
database object and returns the corresponding shortened URL if it already exists using the json()
method of the response object.
server.post('/shorten', express.json(), (req, res) => {
// ...
for (let shortId in urls) {
if (urls[shortId] === originalUrl) {
return res.json({
shortUrl: `http://127.0.0.1:5000/${shortId}`
});
}
}
});
Finally, if the URL hasn't been previously stored, let's generate a new unique identifier using the nanoid
package, use this identifier as key to store the URL in the urls
object, and return a custom URL that includes the identifier.
server.post('/shorten', express.json(), (req, res) => {
// ...
let shortId;
do {
shortId = nanoid(6);
} while (urls[shortId]);
urls[shortId] = originalUrl;
res.json({
shortUrl: `http://127.0.0.1:5000/${shortId}`
});
});
Note that while it's unlikely that the nanoid
package would produce two identical IDs, as a rule of thumb, we should always code for the worst case scenario.
Test the endpoint
Let's now test our endpoint by adding a temporary log that outputs the content of the urls
object every time a new URL is added.
server.post('/shorten', express.json(), (req, res) => {
// ...
urls[shortId] = originalUrl;
console.log(urls);
res.json({
shortUrl: `http://127.0.0.1:5000/${shortId}`
});
});
Let's start the server using the node utility, and let's test various scenarios using the curl command to send HTTP POST requests to the endpoint.
$ node server.js
Server running on port 5000...
When sending any of the following requests:
curl -i -X POST \
-H 'Content-Type: application/json' \
127.0.0.1:5000/shorten
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"url":"https://learnbackend.dev"}' \
127.0.0.1:5000/shorten
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":""}' \
127.0.0.1:5000/shorten
The endpoint should respond with an HTTP 400.
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 11
ETag: W/"b-EFiDB1U+dmqzx9Mo2UjcZ1SJPO8"
Date: Thu, 25 Sep 2025 10:01:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Bad Request
When sending a request with a valid property and a valid URL, the endpoint should respond with an HTTP 200 containing a custom URL.
$ curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"https://learnbackend.dev"}' \
127.0.0.1:5000/shorten
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 43
ETag: W/"2b-XW53NJg2Zk58niT3W5OsFqP6Mcc"
Date: Thu, 25 Sep 2025 10:02:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"shortUrl":"http://127.0.0.1:5000/0k99y7"}
And the server should output the following log.
$ node server.js
Server running on port 5000...
{ '0k99y7': 'https://learnbackend.dev' }
When sending the same request twice, the endpoint should respond with an HTTP 200 containing the same custom URL, and the server should not output a new log.
$ curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"https://learnbackend.dev"}' \
127.0.0.1:5000/shorten
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 43
ETag: W/"2b-XW53NJg2Zk58niT3W5OsFqP6Mcc"
Date: Thu, 25 Sep 2025 10:02:52 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"shortUrl":"http://127.0.0.1:5000/0k99y7"}
Step 5: Redirect Clients to Original URLs
Let's now declare a new HTTP GET endpoint responsible for retrieving an original URL based on its identifier and redirecting the client to it.
// ...
server.post('/shorten', express.json(), (req, res) => {
// ...
});
server.get('/:shortId', (req, res) => {
//
});
// ...
Let's extract the shortId
property from the query string parameters object, and respond with an HTTP 400 Bad Request
if it's not 6 characters long or includes characters that are not letters, digits, underscores, or dashes.
server.get('/:shortId', (req, res) => {
const shortId = req.params?.shortId;
const shortIdRegex = /^[A-Za-z0-9_-]{6}$/;
if (!shortIdRegex.test(shortId)) {
return res.sendStatus(400);
}
});
Let's then attempt to retrieve the original URL from the urls
object using the shortId
variable as key, and respond with an HTTP 404 Not Found
if the key doesn't exist.
server.get('/:shortId', (req, res) => {
const shortId = req.params?.shortId;
const shortIdRegex = /^[A-Za-z0-9_-]{6}$/;
if (!shortIdRegex.test(shortId)) {
return res.sendStatus(400);
}
const originalUrl = urls[shortId];
if (!originalUrl) {
return res.sendStatus(404);
}
});
Finally, let's redirect the user to the original URL using the redirect()
method of the response object.
server.get('/:shortId', (req, res) => {
const shortId = req.params?.shortId;
const shortIdRegex = /^[A-Za-z0-9_-]{6}$/;
if (!shortIdRegex.test(shortId)) {
return res.sendStatus(400);
}
const originalUrl = urls[shortId];
if (!originalUrl) {
return res.sendStatus(404);
}
res.redirect(originalUrl);
});
Test the endpoint
Let's now test our endpoint by first killing the server using CTRL+C
, restarting it, and sending various HTTP GET requests to it using the following curl
commands.
$ node server.js
Server running on port 5000...
{ '0k99y7': 'https://learnbackend.dev' }
^C
$ node server.js
Server running on port 5000...
When sending a request with a valid property and a valid URL, the endpoint should respond with an HTTP 200 containing a custom URL.
$ curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"https://learnbackend.dev"}' \
127.0.0.1:5000/shorten
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 43
ETag: W/"2b-hZUEAXMO2zK+xyNW4ixhoCwEcmo"
Date: Thu, 25 Sep 2025 10:07:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"shortUrl":"http://127.0.0.1:5000/2zvE-2"}
When sending a request with an invalid ID format, the endpoint should respond with an HTTP 400.
$ curl -i 127.0.0.1:5000/xxx
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 11
ETag: W/"b-EFiDB1U+dmqzx9Mo2UjcZ1SJPO8"
Date: Thu, 25 Sep 2025 10:09:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Bad Request
When sending a request with a valid but non-existent ID, the endpoint should respond with an HTTP 404.
$ curl -i 127.0.0.1:5000/xxxxxx
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 9
ETag: W/"9-0gXL1ngzMqISxa6S1zx3F4wtLyg"
Date: Thu, 25 Sep 2025 10:09:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Not Found
When sending a request with a valid and existing ID, the endpoint should respond with an HTTP 302, including the original URL.
$ curl -i 127.0.0.1:5000/2zvE-2
HTTP/1.1 302 Found
X-Powered-By: Express
Location: https://learnbackend.dev
Vary: Accept
Content-Type: text/plain; charset=utf-8
Content-Length: 46
Date: Thu, 25 Sep 2025 10:11:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Found. Redirecting to https://learnbackend.dev
Step 6: Blacklist Malicious and Sensitive URLs
URL shorteners are easy targets for abuse.
As attackers often try to hide links to local machines or private networks behind short IDs, we'll add a tiny policy layer that refuses to shorten URLs pointing to these special ranges:
localhost
- Loopback:
127.0.0.0/8
- Private LANs:
10.0.0.0/8
,172.16.0.0/12
,192.168.0.0/16
- Link-local:
169.254.0.0/16
Implement a blacklist policy check
At the top of the file, let's declare a new global constant named blacklist
, and initialize it with an array of undesirable hostnames.
const express = require('express');
const { nanoid } = require('nanoid');
const server = express();
let urls = {};
const blacklist = ['scam.com', 'phishing.net'];
// ...
Let's then update the /shorten
endpoint one last time to check if the URL's hostname is in the blacklist
array or matches any of the IP patterns specified above, and respond with an HTTP 403 Forbidden
indicating that the request is valid but not allowed by policy.
server.post('/shorten', express.json(), (req, res) => {
// ...
if (
blacklist.includes(originalUrl.hostname) ||
originalUrl.hostname === 'localhost' ||
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(originalUrl.hostname) ||
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(originalUrl.hostname) ||
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(originalUrl.hostname) ||
/^192\.168\.\d{1,3}\.\d{1,3}$/.test(originalUrl.hostname) ||
/^169\.254\.\d{1,3}\.\d{1,3}$/.test(originalUrl.hostname)
) {
return res.sendStatus(403);
}
originalUrl = originalUrl.toString();
// ...
});
Test the endpoint
Let's now test our endpoint by first killing the server using CTRL+C
, restarting it one last time.
$ node server.js
Server running on port 5000...
{ '0k99y7': 'https://learnbackend.dev' }
^C
$ node server.js
Server running on port 5000...
When sending any of the following requests to the HTTP POST /shorten endpoint:
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"localhost"}' \
127.0.0.1:5000/shorten
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"http://127.0.0.1:3000"}' \
127.0.0.1:5000/shorten
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"https://scam.com/signup"}' \
127.0.0.1:5000/shorten
curl -i -X POST \
-H 'Content-Type: application/json' \
-d '{"originalUrl":"https://172.16.12.1"}' \
127.0.0.1:5000/shorten
It should respond with an HTTP 403 Forbidden.
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 9
ETag: W/"9-PatfYBLj4Um1qTm5zrukoLhNyPU"
Date: Fri, 26 Sep 2025 09:52:18 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Forbidden
Conclusion
Congratulations! 🎉
You now have a working Bitly-like URL shortener built with Node.js and Express, complete with validation, normalization, redirects, and basic security checks.
What's next?
✅ Get the code: Run this project on your own machine - download the source code here.
🚀 Go further: If you're serious about backend development, check out the Learn Backend Mastery Program - a complete zero-to-hero roadmap to become a professional Node.js backend developer and land your first job in 12 months
👉 Learn more at learnbackend.dev
Top comments (0)