Have you ever noticed that you can completely butcher the title part of a Stack Overflow URL and it still works? Try it yourself: take a URL like https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git and change the slug to complete nonsense: https://stackoverflow.com/questions/927358/banana-pancake-recipe. It will redirect you to the correct URL with the actual title. This is what we call a self-healing URL.
You'll see this pattern all over the web: YouTube, Medium, Reddit, and many other content-heavy sites use it. The basic idea is straightforward: the ID is the source of truth, the slug is just for humans. If the slug is wrong or outdated, the site heals itself by redirecting to the correct one.
SEO and shareability. A URL like /posts/how-to-bake-cookies is infinitely better than /posts/123 for search engines and for people sharing links. But what happens when you update the title from "How to Bake Cookies" to "The Ultimate Guide to Baking Cookies"? All those shared links break. Self-healing URLs solve this: old links redirect to the new slug automatically.
Typo tolerance. Someone manually types your URL and makes a typo in the slug? No problem. As long as the ID is correct, they'll get there.
Content evolution. Titles change. Typos get fixed. Better keywords get identified. Your URLs should be resilient to all of this.
Here's a Next.js App Router example. The file structure is app/posts/[id]/[[...slug]]/page.tsx. The [[...slug]] makes the slug optional using catch-all segments.
import { notFound, redirect } from "next/navigation";
import { getPostById } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: { id: string; slug?: string[] };
}) {
const postId = parseInt(params.id);
const providedSlug = params.slug?.[0];
const post = await getPostById(postId);
if (!post) {
notFound();
}
// The self-healing part
if (providedSlug !== post.slug) {
redirect(`/posts/${post.id}/${post.slug}`);
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}That's it. Now all of these work:
/posts/1/how-to-bake-cookies✓ (renders)/posts/1/wrong-slug→ redirects to/posts/1/how-to-bake-cookies/posts/1→ redirects to/posts/1/how-to-bake-cookies
The redirect is a 308 by default (permanent redirect), which is what you want for SEO. Search engines will update their index to the canonical URL.
For slug generation, just use slugify. Don't reinvent the wheel. It handles unicode, special characters, and all the edge cases you don't want to think about.
Missing slug: I redirect to the canonical URL even when the slug is missing. This adds a redirect hop, but ensures consistency. You could skip the redirect and render directly if you want to save the round trip.
Duplicate slugs: Since lookups are by ID, duplicate slugs aren't technically a problem. But it's confusing for users to see identical URLs pointing to different content. You can either:
- Append a counter:
how-to-bake-cookies-2 - Include the ID in the slug:
how-to-bake-cookies-a1b2c3d4(like Medium) - Just let it happen and live with it
Wrong ID: If the ID doesn't exist, you get a 404. The slug is irrelevant at that point.
The redirect adds a round trip, but it's minimal. If you're behind a CDN like Cloudflare, the 308 redirects get cached at the edge anyway. Subsequent requests won't even hit your server.
For SPAs, you can implement the "redirect" client-side using your router's navigation API. No HTTP round trip at all.
That's all there is to it. Self-healing URLs are one of those patterns that seem overengineered at first but pay dividends once your content starts evolving. Next time you see a mangled Stack Overflow link still working, you'll know exactly what's happening under the hood.