DEV Community

Cover image for How to Build a Bitly-Like URL Shortener API with Node + Express
Razvan
Razvan

Posted on • Originally published at learnbackend.dev

How to Build a Bitly-Like URL Shortener API with Node + Express

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Server

Within the root directory, let's create a new file for the API named server.js.

touch server.js
Enter fullscreen mode Exit fullscreen mode

And within it:

  1. Import the express package.
  2. Create a new server instance by invoking the top-level function exported by the package.
  3. 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...');
});
Enter fullscreen mode Exit fullscreen mode

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>
};
Enter fullscreen mode Exit fullscreen mode

Where:

  • <shortId> is a unique string identifier for a shortened URL of 6 characters exactly generated using the nanoid 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'
};
Enter fullscreen mode Exit fullscreen mode

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...');
});
Enter fullscreen mode Exit fullscreen mode

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) => {
  //
});

// ...
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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(/\/+$/, '');
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 = '';
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

💡 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.

👉 learnbackend.dev


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');

// ...
Enter fullscreen mode Exit fullscreen mode

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}`
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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}`
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`
  });
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

And the server should output the following log.

$ node server.js
Server running on port 5000...
{ '0k99y7': 'https://learnbackend.dev' }
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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) => {
  //
});

// ...
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'];

// ...
Enter fullscreen mode Exit fullscreen mode

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();

  // ...
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)