I’m working on an e-commerce app in Node.js. The store has SEO-friendly URLs – /blue-widget/SKU12345/p/ for products, /category-name.html for categories, /brand-name/ for brands. Those URLs are indexed, bookmarked, linked from everywhere. They can’t change.

Express routes are defined in code. The catalog has enough products, categories, and brands that I’m not writing a route for each one. I need a way to map the friendly URL to an internal route at runtime.

The URL hash

At startup, we load every URL into a Redis hash. One key per URL, one value per internal route:

/blue-widget/SKU12345/p/   →  /catalog/product/42
/category-name.html        →  /catalog/category/view/15
/brand-name/               →  /catalog/brand/7

Four loaders run at boot – products, categories, brands, CMS pages. Each one queries the database for all active entries, builds the key-value pairs, and writes them into a Redis hash called url:

loadProducts: function(urls, cb) {
  Product.active(function(err, items) {
    for (var i = 0; i < items.length; i++) {
      var slug = '/' + items[i].url_key + '/' + items[i].sku + '/p/';
      urls[slug.toLowerCase()] = '/catalog/product/' + items[i].id;
    }
    cb(err, urls);
  });
}

Same shape for categories, brands, CMS. Build the map, write to Redis. Total startup time depends on catalog size.

The router middleware

An Express middleware intercepts every request before routing. It looks up the URL in the Redis hash. If it finds a match, it rewrites req.url to the internal route and calls next(). Express then routes the rewritten URL normally.

function urlRouter(req, res, next) {
  var parts = req.url.split('?');
  var path = parts[0].toLowerCase();
  var candidates = [path];

  // try with and without trailing slash
  if (path[path.length - 1] === '/') {
    candidates.push(path.slice(0, -1));
  } else {
    candidates.push(path + '/');
  }

  redis.hmget('urls', candidates, function(err, mapped) {
    if (mapped[0] || mapped[1]) {
      parts[0] = mapped[0] || mapped[1];
      req.url = parts.join('?');
    }
    next();
  });
}

Every request costs one Redis hmget. Two lookups per request – with and without trailing slash – because URLs in the wild are inconsistent and I’d rather check both than lose a customer to a 404.

Query parameters survive the rewrite. req.url gets the internal route, but everything after ? stays intact. The product page doesn’t know or care that the URL was /blue-widget/SKU12345/p/?ref=homepage on the outside.

Redirects

Old URLs need to work too. The URL scheme changed at some point before I got here. Customers have bookmarks. Google has cached pages. We keep a second Redis hash – redirect-url – that maps old URLs to new ones. If the main lookup misses, the middleware checks the redirect hash and issues a 301.

When the catalog changes

New products go live throughout the day. We can’t restart the server every time. When a product is added or updated, a single-URL loader checks if the mapping changed and writes only if it did:

loadOne: function(id, cb) {
  Product.find(id, function(err, item) {
    var slug = ('/' + item.url_key + '/' + item.sku + '/p/').toLowerCase();
    var route = '/catalog/product/' + item.id;
    redis.hget('urls', slug, function(err, existing) {
      if (existing === route) return cb(null, null);
      redis.hmset('urls', slug, route, 'product-' + item.id, slug, cb);
    });
  });
}

The reverse mapping – product-42/cotton-kurta/SKU12345/p/ – lets us generate URLs from IDs without querying the database. Templates can build product links by looking up product-{id} in the hash.

So far

The entire URL space lives in one Redis hash. A few thousand keys, a few hundred kilobytes. Lookups are sub-millisecond. If Redis is empty at boot, the server exits – we’d rather fail loud than serve 404s to the whole catalog.

The thing I didn’t expect: once the URL mapping exists, we can reorganize the Express routes freely without breaking anything on the outside. The mapping layer absorbs it.