← Complete Projects

阅读中文版本

Build a Slug and Redirect Service

A complete slug and redirect service: unique slug generation, reserved-word handling, 301/302 policy, redirect chains, and collision tests.

intermediate~3 hseoredirectsnodepostgresqlrouting

Project goal

Assign stable, unique slugs to content and redirect old slugs to new ones with correct status codes and no chains or loops.

Final features

  • Normalize slug
  • Uniqueness with suffix
  • Reserved words
  • Rename chain
  • Resolve with hop limit

Architecture

Slugs are normalized, checked against a reserved list, and stored with a unique constraint. Renaming writes a redirect row; resolution follows redirects until it reaches a canonical slug, rejecting any loop beyond a small hop limit.

Prerequisites

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • A content table with a slug column

Dependencies

  • pg ^8.12.0 — Slug and redirect storage
  • express ^4.21.0 — Resolution endpoint

Directory structure

slug-service/
├── src/slugify.ts
├── src/assign.ts
├── src/rename.ts
├── src/resolve.ts
├── tests/slug.test.ts
└── package.json

Phases

1. Normalize slug

Lowercase, transliterate, and collapse separators.

const slug = raw.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

Expected behavior: A consistent form makes equality and lookups reliable.

2. Uniqueness with suffix

Append a counter until the slug is free.

while (await exists(candidate)) candidate = base + '-' + (++n);

Expected behavior: Two titles can normalize to the same slug.

3. Reserved words

Block slugs that collide with routes like login or admin.

if (RESERVED.has(slug)) throw new Error('reserved slug');

Expected behavior: A content slug must not shadow a real application route.

4. Rename chain

Repoint existing redirects to the new canonical slug.

await db.updateRedirectsTo(oldSlug, newSlug);

Expected behavior: Chains degrade SEO and latency.

5. Resolve with hop limit

Follow redirects up to a small limit and return 301 or 404.

for (let i = 0; i < 5; i++) { target = await nextRedirect(target); if (!target) return target; } return null;

Expected behavior: A loop must terminate instead of hanging.

Testing

Assign two identical titles and assert distinct slugs; rename a slug and assert the old one redirects with 301; create a manual loop and assert resolution stops at the hop limit.

Troubleshooting

Old links break after rename

The rename updated the slug but did not write the redirect row.

Redirect loops

Two renames pointed at each other; repoint all redirects to the canonical slug on rename.

Deployment

Serve redirects at the edge so they are fast and cheap. Keep the mapping in a database and export a snapshot to the edge cache on change.

Known limitations

  • No case-insensitive duplicate detection across languages.
  • Single canonical target per slug.
  • Expiry and soft-delete of slugs are not implemented.

Project overview

Slugs are the stable public identity of a piece of content, and a rename must not break every link that points at the old name. This build assigns unique slugs and turns a rename into a redirect rather than a broken URL.

You will normalize a title into a slug, resolve collisions with a suffix, reject reserved words, write a redirect row on rename, and resolve chains with a hop limit so a loop cannot hang a request.

The hop limit is the safety valve. A rename creates a redirect, and a second rename creates a redirect to the first redirect, so without a limit a misconfiguration becomes an infinite loop that a crawler will happily follow.

The unique constraint in the store is what makes slug assignment race-free: two titles normalized to the same slug cannot both be inserted, and the loser takes a suffix rather than corrupting the mapping.

Each phase adds one property: normalization, uniqueness, reserved words, the rename chain, and bounded resolution, so slugs are stable and safe at every stage.

It is written for teams whose content has moved and whose analytics show a cliff in traffic because old links stopped resolving. It treats a rename as an event that appends to a mapping rather than as an edit that breaks history.

It is also a small lesson in identity: an identifier that once existed is part of the public contract of a site, so preserving it is a courtesy to every link that was ever shared.

What you will build

The service provides five capabilities, each with a test that assigns or resolves a slug:

  • Normalize slug
  • Uniqueness with suffix
  • Reserved words
  • Rename chain
  • Resolve with hop limit

Normalization produces a canonical slug; a unique constraint plus a suffix resolves collisions; a reserved list keeps system paths free; a rename writes a redirect; and resolution follows the chain with a hop limit and rejects a loop.

The design treats a slug as permanent and a rename as an event, so the mapping only ever grows and a link that worked yesterday keeps working today.

Architecture and data model

Slugs are normalized, checked against a reserved list, and stored with a unique constraint. Renaming writes a redirect row; resolution follows redirects until it reaches a canonical slug, rejecting any loop beyond a small hop limit.

A title is normalized into a slug and checked against the reserved list, then stored with a unique constraint so two requests cannot claim the same slug. A collision takes a deterministic suffix rather than failing the request.

Renaming a piece of content does not update the old slug; it writes a redirect row from the old slug to the new one, so the historical identity remains resolvable.

Resolution follows redirect rows until it reaches a canonical slug, counting hops and rejecting any chain beyond a small limit so a loop fails loudly instead of hanging.

The mapping lives in the database and is exported as a snapshot to the edge, so resolution is fast and cheap while the database remains the source of truth.

Keeping the old slug and writing a redirect is what separates this from a naive update, because an update destroys the only record that the old URL ever existed; the mapping therefore grows monotonically and is never rewritten in place.

Resolution is deliberately read-only and never writes, so a request that follows a chain cannot itself create a redirect or mutate the mapping.

Prerequisites and dependencies

You need a database with a unique constraint and, for production, an edge layer that can serve the mapping as a snapshot.

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • A content table with a slug column

The dependencies cover the HTTP surface, the database, and the slug utilities, with the redirect served from the edge:

  • pg (^8.12.0) — Slug and redirect storage
  • express (^4.21.0) — Resolution endpoint
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest

Fix the reserved word list and the hop limit in configuration, since both are policy decisions that should be reviewable and changeable without a code deploy.

Directory structure

Separate normalization, uniqueness resolution, the reserved list, redirect writing, and chain resolution so each can be tested with a fixed mapping:

slug-service/
├── src/slugify.ts
├── src/assign.ts
├── src/rename.ts
├── src/resolve.ts
├── tests/slug.test.ts
└── package.json

Phase 1: Normalize slug

Lowercase, transliterate, and collapse separators.

const slug = raw.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

A consistent form makes equality and lookups reliable.

Normalize the title into a slug deterministically: lowercase, replace runs of separators, trim leading and trailing separators. A normalization change silently changes every future slug, so it is a contract rather than a helper.

Phase 2: Uniqueness with suffix

Append a counter until the slug is free.

while (await exists(candidate)) candidate = base + '-' + (++n);

Two titles can normalize to the same slug.

Resolve collisions with a short deterministic suffix and rely on the unique constraint to catch the race, because two concurrent requests for the same title must not produce one slug and one error.

Phase 3: Reserved words

Block slugs that collide with routes like login or admin.

if (RESERVED.has(slug)) throw new Error('reserved slug');

A content slug must not shadow a real application route.

Reject reserved words such as administrative paths, because a content slug that shadows a system route is a routing bug waiting to be exploited.

Phase 4: Rename chain

Repoint existing redirects to the new canonical slug.

await db.updateRedirectsTo(oldSlug, newSlug);

Chains degrade SEO and latency.

Write a redirect row on rename rather than updating the old slug, so a link to the old name still resolves and analytics keep attributing traffic to the old URL it recorded.

Phase 5: Resolve with hop limit

Follow redirects up to a small limit and return 301 or 404.

for (let i = 0; i < 5; i++) { target = await nextRedirect(target); if (!target) return target; } return null;

A loop must terminate instead of hanging.

Resolve a chain of redirects with a hop limit and reject a loop, so a misconfigured rename fails deterministically rather than hanging a crawler or a user.

Testing

Assign two identical titles and assert distinct slugs; rename a slug and assert the old one redirects with 301; create a manual loop and assert resolution stops at the hop limit.

Assign two identical titles and assert distinct slugs, which pins the collision path as behavior rather than as an assumption about timing.

Rename a slug and assert the old one redirects with a permanent status, because a temporary status tells crawlers to keep the old URL and defeats the point.

Create a manual loop and assert resolution stops at the hop limit, since a loop that hangs is the one failure mode a limit exists to prevent.

Assert that a reserved word is refused, because a content slug shadowing a system path is a routing and possibly security problem.

Assert that normalization is stable across repeated calls with the same input, because a non-deterministic slug turns every re-index into a new URL and breaks caching and analytics together.

Assert too that the collision suffix is deterministic, so the same two titles always produce the same pair of slugs rather than one that depends on request order.

pnpm vitest run

Deployment and operations

Serve redirects at the edge so they are fast and cheap. Keep the mapping in a database and export a snapshot to the edge cache on change.

Serve redirects at the edge so they are fast and cheap, and export a snapshot of the mapping on change, because a redirect looked up in the database on every request is avoidable latency.

Keep the database as the source of truth and treat the edge snapshot as a cache, so a missing snapshot entry still resolves correctly from the database.

Monitor the redirect hit rate and the chain depth, because a growing average depth means renames are chaining rather than reaching a canonical slug.

Alert on resolution failures, since a loop or a broken chain is a signal that a rename was applied incorrectly.

Export the mapping snapshot atomically so the edge never serves a partial mapping, because a half-updated snapshot produces a window in which some links resolve and others do not.

Warm the edge snapshot after a deploy and compare its entry count with the database, because a snapshot that silently drops entries produces 404s that look like content bugs.

Security considerations

Reject slugs that look like paths or encoded sequences, and rate-limit creation to prevent slug squatting. Escape slugs in generated HTML.

Reject a slug that could be interpreted as a path traversal or as a reserved route, because user-supplied content should never be able to shadow a system path.

Rate-limit slug assignment, since an endpoint that writes a mapping per request can be used to grow the table without bound.

Validate the redirect target, so an internal rename cannot be crafted to point at an external site and turn the service into an open redirector.

Keep the reserved list under the same access control as routing configuration, because adding a reserved word changes what content can be published.

Validate the redirect target against an allowlist of internal prefixes, because a target that can point anywhere makes the service an open redirector that phishers will find.

Do not let a slug contain an encoded separator that decodes into a path segment, because that is how a normalized-looking slug reaches an unexpected route.

Observability

Track redirect hits, 404 rates, and chain lengths. Alert on chains exceeding one hop, which signals missed repointing.

Track redirect requests and their status mix, because a rise in temporary redirects means something is writing the wrong status.

Chart the chain depth distribution, since a long tail of deep chains is the early sign that renames are not being consolidated.

Measure resolution latency by depth, because a rare deep chain can dominate the p99 even when most redirects are one hop.

Alert on resolution failures, because a broken chain means links are silently 404ing for real users and crawlers.

Track the share of requests that resolve in one hop versus several, because a growing multi-hop share means renames are chaining and the mapping is not being flattened.

Alert on a rise in permanent redirects to recently created slugs, because that pattern usually indicates a bulk rename applied without consolidating the mapping.

Track the number of redirects created per rename operation, because a bulk rename that creates thousands is worth reviewing before it reaches the edge.

Troubleshooting

Old links break after rename. The rename updated the slug but did not write the redirect row.

Redirect loops. Two renames pointed at each other; repoint all redirects to the canonical slug on rename.

If a redirect loops, check the hop limit and the mapping, because a limit that is too generous masks a loop that should have been detected and fixed at the source.

If two pieces of content share a slug, check the unique constraint and the collision logic, because a missing constraint lets a race write both.

If a redirect returns a permanent status for a temporary move, check the status the rename path writes, because a temporary move recorded as permanent is very hard to undo with crawlers.

If the edge serves an old mapping after a rename, check the export trigger, because a snapshot exported before the write commits will be one generation behind.

Limitations and next steps

  • No case-insensitive duplicate detection across languages.
  • Single canonical target per slug.
  • Expiry and soft-delete of slugs are not implemented.

Slugs are single-segment strings and do not model hierarchical paths, so a nested content structure would need a different addressing scheme.

There is no automatic consolidation of chains, so a sequence of renames leaves a chain unless an operator flattens it.

The reserved list is static and global, so a per-section reserved word would need a richer policy.

The edge snapshot is eventually consistent, so a rename is briefly resolvable from the database but not from the edge.

There is no automatic detection of a duplicate content slug created by two different sources, which would need a canonical-content concept the example does not include.

There is no expiry on redirect rows, so the mapping grows forever; a rarely used redirect is cheap, but a pruning policy would need usage data to be safe.

The example also does not model a vanity redirect that a user registers explicitly, which would need a separate namespace and its own moderation.