Build a URL Shortener With Node.js and Redis

A URL shortener is two writes and one read, and those writes decide how long the link you handed someone keeps working. Redis counts that lifetime down on its own, which means a route handler that looks correct today can answer 404 tomorrow.

I built this one against the packages that are current right now and ran it end to end against node-redis 6.2.1 on Node 26.7.0, Express 5, and an eight-character code from Nano ID. Then I read the remaining seconds off a live key and watched the count fall to -2.

What a URL Shortener Keeps in Redis

A shortener answers one question in both directions. It turns a long address into a short code, then turns that code back into the address a browser follows.

Redis holds each direction as its own string key, and the value of one key is the name of the other. That pairing is what lets a second request for the same address return the code you already issued.

KeyValueRead by
link:${code}the target URLthe redirect route
url:${target}the code already issued for that targetthe create route

The reverse key carries no data the first key lacks, so it looks like waste. Drop it and the create route can no longer tell a repeat request from a new one. The same address ends up with two live codes that nobody can reconcile.

Both keys are written with the same expiry in the code below. That number is the decision this page keeps returning to.

What You Need Before the First Request

You need three things in place, and one of them is a choice rather than an install.

  • A current Node.js release, because Nano ID ships as an ES module and the sample loads it with an import.
  • A Redis server you can reach, local or hosted, with its connection string.
  • The Redis CLI, so you can read a key’s remaining lifetime from a terminal instead of guessing.

The connection string is the only value that changes between your laptop and a host, so keep it in an environment variable and out of the client code.

mkdir urlshortener && cd urlshortener
npm init -y
npm install express redis cors nanoid

That is four packages, where an older version of this build installed six. Express parses a JSON body on its own now, and the URL constructor replaced the validation package. The identifier generator this page used to lean on has been deprecated by its own author in favour of Nano ID.

The project has to be an ES module for the import syntax to work, and npm writes a CommonJS package by default. Add one line before the code below runs.

{
  "type": "module",
  "scripts": { "start": "node app.js" }
}

How to Build the Shortener in Node.js

The order matters here more than usual. The client, the code generator and the two routes each depend on the one before them, so the failures stay small while you build.

Connect Redis Before the First Command

The confusion that shows up in the wild is worth naming before you hit it. A client object that exists is not the same as a connected one, so a command issued too early rejects instead of waiting.

import { createClient } from 'redis';

export const TTL_SECONDS = Number(process.env.LINK_TTL_SECONDS ?? 60 * 60 * 24 * 30);

export const redis = createClient({
  url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
});

redis.on('error', (error) => console.error('redis error:', error.message));

In node-redis 4 and later the host, port and password options moved into a URL string, which is why the older call shape no longer compiles. I ran a get before awaiting connect on this build and the promise rejected with ClientClosedError, the client reporting that it is closed.

The connect call sits at the bottom of the route file rather than at the top of the module, because top-level await is what makes it safe to import anything that touches the client.

Generate a Code That Cannot Collide

A counter would be shorter, and predictable, which is exactly the problem. Anyone can walk your links by adding one to the last code they saw, so the code has to come from a random source instead.

import { customAlphabet } from 'nanoid';

const CODE_ALPHABET =
  'useandom26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
const newCode = customAlphabet(CODE_ALPHABET, 8);

async function claimCode(targetUrl) {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const code = newCode();
    const written = await redis.set(`link:${code}`, targetUrl, {
      EX: TTL_SECONDS,
      NX: true,
    });
    if (written === 'OK') return code;
  }
  throw new Error('could not allocate a unique code');
}

The interesting decision inside that loop is the third argument. Redis accepts a NX condition on a set, so the write only lands when the key is free. A collision comes back as null, which the loop treats as a retry rather than an overwrite.

I checked the exact command the client sends by watching the server with MONITOR, and the option object arrives as native Redis options with nothing hidden in between.

1789560303.642423 "GET" "url:https://codeforgeek.com/url-shortener-node-js-redis/"
1789560303.642423 "SET" "link:SyyfBr_r" "https://codeforgeek.com/url-shortener-node-js-redis/" "EX" "2592000" "NX"
1789560303.642909 "SET" "url:https://codeforgeek.com/url-shortener-node-js-redis/" "SyyfBr_r" "EX" "2592000"

Write Both Keys With One Lifetime

The two writes are where the shortener stops being a redirect and becomes a promise about time. Both carry an expiry in seconds, and the same number lands on both.

export async function storeURL(targetUrl) {
  const existing = await redis.get(`url:${targetUrl}`);
  if (existing) return existing;

  const code = await claimCode(targetUrl);
  await redis.set(`url:${targetUrl}`, code, { EX: TTL_SECONDS });
  return code;
}

export async function findURL(code) {
  return redis.get(`link:${code}`);
}

The read path is a single get. A key that expired and a key that never existed both come back as null, so the route cannot tell them apart and does not need to.

If you want a link to outlive a browsing session, raise the number now, because nothing in the request path can extend a key afterwards.

Expose the Create Route and the Redirect

Those two routes carry the whole product. The create route takes an address and answers with a code, and the redirect route turns that code back into the address.

import express from 'express';
import cors from 'cors';
import { redis, findURL, storeURL, ttlOf } from './models.js';

const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static('views'));

app.get('/:code', async (req, res, next) => {
  try {
    const target = await findURL(req.params.code);
    if (!target) {
      return res.status(404).json({ error: 'unknown or expired code' });
    }
    res.redirect(302, target);
  } catch (error) {
    next(error);
  }
});

app.post('/api/short', async (req, res, next) => {
  const raw = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
  let parsed;
  try {
    parsed = new URL(raw);
  } catch {
    return res.status(400).json({ error: 'not a valid URL' });
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return res.status(400).json({ error: 'only http and https URLs are shortened' });
  }

  try {
    const code = await storeURL(parsed.toString());
    res.json({
      code,
      shortUrl: `${req.protocol}://${req.get('host')}/${code}`,
      ttl: await ttlOf(code),
    });
  } catch (error) {
    next(error);
  }
});

const port = process.env.PORT ?? 3000;
await redis.connect();
app.listen(port, () => {
  console.log(`listening on http://127.0.0.1:${port}`);
});
Terminal output showing a 302 response with the target in the Location header and a 404 response for an unknown short code
The redirect answers 302 with the target in the Location header, and an unknown code gets a 404 instead of a crash.

A 302 tells the browser and any crawler that this hop is temporary, and a 301 is cached for good in some clients. A cached 301 keeps sending traffic to a target you have since changed, so keep the 302 unless the destination is permanent.

The route validates the address with the built-in URL constructor, which throws on nonsense, so the try block rejects bad input before a key is written. Anything that is not http or https is refused for the same reason, since a shortener for a file path or a mail link is not useful.

Serve a Form and Run It

The front end is one fetch call, and it exists to prove the API works from a browser rather than from curl.

<script type="module">
  const result = document.getElementById('result');
  document.getElementById('submit').addEventListener('click', async () => {
    const response = await fetch('/api/short', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: document.getElementById('url').value }),
    });
    const data = await response.json();
    result.textContent = response.ok
      ? `${data.shortUrl} (ttl ${data.ttl}s)`
      : `error: ${data.error}`;
  });
</script>

Start the server with the port the machine has free, because 3000 is a popular default and something else may already hold it.

PORT=3100 node app.js
Terminal output showing two identical JSON responses for the same URL followed by a Redis key scan listing the code key and the reverse key
The same address twice returns the same code, and the key scan shows the pair Redis is holding.

The two responses are identical on purpose, and the key scan is why. The create route found the reverse key on the second call and returned the code it pointed at, so the pair stayed two keys instead of becoming four.

Edge Cases That Decide Whether a Link Survives

Everything above works while both keys are alive. These are the three states that decide whether the link you hand out still works when somebody clicks it later.

The Lifetime You Never See in the Save Call

The expiry sits in the options object of a write, so nothing in the route, the response or the page shows the reader that a clock is running. Read it back from Redis whenever the number matters.

TTL readingWhat it means
a positive integerseconds left before the key is deleted
-1the key exists with no expiry at all
-2the key is gone, so the code resolves to nothing
redis-cli TTL link:B8F92qV6
redis-cli --scan --pattern 'link:*'
redis-cli PERSIST url:https://example.com/
Terminal output showing a key's TTL counting down to -2 after a sleep, followed by a key scan where that code is absent
A twenty-second lifetime drains to -2, and the code no longer appears in the key scan.

The -2 in that output is the whole failure, and I read it off the key myself after the wait. Redis did not raise an error when the key died, it simply stopped returning it, so the next request for that code walks into the 404 branch.

The Reverse Key Expires With the Code

Because both writes carry the same number, a repeated address loses its shortcut at the same moment the link dies. I shortened one address, waited for both keys to expire, and the next request for the same address produced a different code while the first one stayed dead.

If a stable code per address matters to you, give the reverse key a longer life than the link itself, so the mapping outlives any single link.

// the code lives a day, the lookup that de-duplicates it does not
await redis.set(`link:${code}`, targetUrl, { EX: 60 * 60 * 24 });
await redis.set(`url:${targetUrl}`, code, { EX: 60 * 60 * 24 * 365 });

The trade is storage against continuity. Every address you retain keeps a key forever, and Redis will not tidy it up for you, so pick a window you are willing to pay for.

The Removed Save Call Is Not Coming Back

Plenty of shortener code still in circulation uses the callback form of the old client. I ran that call shape against the current client and it failed with a closed-client error rather than a deprecation warning. The fix is a rewrite of the call, not a version pin.

// what the same write looked like before node-redis 4
const redis = require('redis').createClient(url, {
  host: '127.0.0.1',
  port: 6379,
  password: undefined,
});
redis.get('link:abc', (err, reply) => { /* ... */ });

The shape to remember is that the second argument is now an options object with a url field. The methods return promises, and connect has to resolve before anything else runs.

Check the Lifetime Before You Hand the Link Out

A shortener is a storage problem with a deadline attached, and the deadline is the part a reader notices. Pick the number deliberately, write it in one place, and read it back off the key before the link leaves your machine.

redis-cli TTL link:B8F92qV6
redis-cli SET link:B8F92qV6 "https://example.com/" EX 31536000 XX

FAQ

How long does a short link built this way stay alive?

As long as the number you pass on the two writes, because both keys carry it. The sample uses thirty days, and the key disappears the second that count reaches zero, so the code you handed out answers 404 from then on.

Why does shortening the same address twice return the same code?

The create route reads the reverse key before it generates anything. A hit on that key means the address already has a code, so the route returns the existing one instead of writing a second pair of keys.

What do I change for a link that must never expire?

Leave the expiry off the write entirely, which stores the key with no time limit and makes TTL report -1. Do that only for links you are prepared to keep forever, because Redis will hold the key until you delete it.

Does the redirect need a 301 instead of a 302?

Only if the destination never changes and you want browsers to cache the hop permanently. For anything you might repoint later, keep the 302, since a cached 301 keeps sending visitors to the old target after you update the key.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337