Subscribe to šŸ’Œ Tiny Improvements, my weekly newsletter for product builders. It's a single, tiny idea to help you build better products.

How to set up self-healing URLs in Next.js for better SEO

Set up self-healing URLs with the App Router in Next.js for better SEO, accessibility, and usability

note: This post is inspired by a great video from YouTuber Aaron Francis: Make self-healing URLs with Laravel. For all my PHP homies - go check that video out. It's great!

A demo of a self-healing URL made in Next.js
Ever wonder how Amazon and Medium create self-healing URLs?

Building sites with human-readable URLs is helpful for a few reasons: it gives users a better understanding of what they're getting when they click on a link to your site, it's better for SEO, and it's more accessible (screen readers can read out the URL to the user and they can understand where they are on the site). It's also just a nice thing to do for your users... by way of example, these two URLs feel dramatically different:

https://example.com/posts/c61sca0

vs

https://example.com/posts/how-to-set-up-self-healing-urls-in-nextjs-for-better-seo

The first one is a bit of a mystery box. You have no idea what you're going to get when you click on it. The second one is much more descriptive, and hopefully the contents of the page match the URL.

...but, what happens if you change the title of the post? The human-readable URL is now misleading. If you're using a static site generator like Next.js, you can use the file system to create self-healing URLs that will always point to the correct page, even if the title changes.

Or, what if you're managing a larger site, where it's possible that two posts may have the same title?

These are both issues that self-healing URLs are attempting to solve.

What are self-healing URLs?

Self-healing URLs are URLs that allow for human-readable text, but also include a unique identifier that will always point to the correct page, even if the human-readable text changes, or gets removed entirely.

In this post, we'll learn how to set up self-healing URLs in Next.js. This is something used on sites like Amazon and Medium to make sharing links a little more robust. These are URLs that allow for human-readable text, but also include a unique identifier that will always point to the correct page, even if the human-readable text changes, or gets removed entirely.

Here's the URL-pattern we're to build: https://example.com/posts/${POST_TITLE}-${POST_ID}.

Ultimately, we'll use the id of a post to identify which content to render on a page, and we'll use the title of the post to create a human-readable portion in the URL. As long as the URL ends with a hyphen followed by the id, we will redirect to the correct page, with a nicely formatted, human-readable URL.

Self-healing URLs in Next.js with the App router

To create self-healing URLs for blog posts with the Next.js App router, we'll start with a fresh react app from the cli:

1
# with npm
2
npx create-next-app
3
4
# or, with yarn
5
yarn create next-app
6
7
# or, with pnpm
8
pnpm create next-app

Give your project a name, and select the options you want to use. I used all the defaults for this example:

1
āœ” What is your project named? ā€¦ next-self-healing-urls-app-router
2
āœ” Would you like to use TypeScript? Yes
3
āœ” Would you like to use ESLint? Yes
4
āœ” Would you like to use Tailwind CSS? Yes
5
āœ” Would you like to use `src/` directory? Yes
6
āœ” Would you like to use App Router? (recommended) Yes
7
āœ” Would you like to customize the default import alias (@/*)? No

You'll also need to install the slugify package, which we'll use to create a URL-safe slug from the post's title:

1
npm install slugify
2
3
# or, with yarn
4
yarn add slugify
5
6
# or, with pnpm
7
pnpm add slugify

Set up dynamic routing

We'll need to add a few files to the default setup. Using the App directory, create the following directories and files:

1
app
2
ā”œā”€ā”€ posts
3
ā”‚ ā””ā”€ā”€ [slug] // note that this is a _folder_ called [slug]. This is how dynamic routes are set up in the app router
4
ā”‚ ā””ā”€ā”€ page.tsx // render one post
5
ā”œā”€ā”€ not-found.tsx // this will render if someone tries to visit a post page with an id that doesn't exist in the url
6
ā”œā”€ā”€ sitemap.ts // generate a dynamic sitemap with the _correct_ canonical URL for each post
7
ā””ā”€ā”€ utils
8
ā””ā”€ā”€ post.ts // types and helper functions to load posts and format URLs

Let's start with post.ts. This file contains a type definition and helper functions to load posts and format URLs.

1
import slugify from 'slugify';
2
3
export type Post = {
4
userId: number;
5
id: number;
6
title: string;
7
content: string;
8
};
9
10
export const getAllPosts = async () => {
11
// fetch all posts from an example API.
12
// this is just a placeholder, replace with your own data
13
// shout out to https://jsonplaceholder.typicode.com/ - what a great service!
14
const postsResponse = await fetch(
15
'https://jsonplaceholder.typicode.com/posts',
16
);
17
const posts = (await postsResponse.json()) as Post[];
18
19
return posts;
20
};
21
22
export const getPostById = async (id?: string | number) => {
23
if (!id) {
24
throw new Error('No ID provided');
25
}
26
27
if (typeof id === 'string') {
28
id = parseInt(id);
29
}
30
31
const allPosts = await getAllPosts();
32
33
const post = allPosts.find((post) => post.id === id);
34
35
if (!post) {
36
// we'll use a try/catch block around this function later
37
// to redirect if a post doesn't exist with this id,
38
// so make sure to throw an error here.
39
throw new Error('Post not found');
40
}
41
42
return post;
43
};
44
45
/**
46
* Converts input to a URL-safe slug
47
* @param {string} title - the title of the post
48
* @returns {string} a URL-safe slug based on the post's title
49
*/
50
const titleToSlug = (title: string) => {
51
const uriSlug = slugify(title, {
52
lower: true, // convert everything to lower case
53
trim: true, // remove leading and trailing spaces
54
});
55
56
// encode special characters like spaces and quotes to be URL-safe
57
return encodeURI(uriSlug);
58
};
59
60
// given a post, return its slug
61
export const getPostSlug = (post: Post) => {
62
return `${titleToSlug(post.title)}-${post.id}`;
63
};
64
65
// given any slug, try to extract an id from it
66
export const getIdFromSlug = (slug: string) => slug.split('-').pop();
67

Generate a sitemap with the correct canonical URL for each post

Let's talk SEO for a moment: Google (and other search engines) use your site's sitemap file to understand the structure of your site. This helps them index your site more accurately, and it helps them understand which pages are the most important. Next.js supports static and dynamic sitemap generation, which you can read about in their docs on sitemap.xml. Your sitemap should contain the canonical URL for every page in your site.

When Google and other search engines crawl your site, they use the Sitemap as an address book, and then visit each page found in the sitemap to analyze the content on the page, and deciding how to rank it in search results. If the URL in the address bar doesn't match the URL that Google has indexed for that page, it can cause problems with SEO. For example, if you have a page with the URL https://example.com/foo/bar/some-url-here, but Google has indexed the page as https://example.com/foo/bar/some-other-url-here, Google will think that you have two pages with the same content, and it will penalize you for duplicate content.

This is where the canonical tag comes in. The canonical tag is a special bit of metadata that you can add to your page's <head> tag, which is used to tell search engines which URL is the original URL for the content onthat page. Ideally, the canonical URL for our blog posts should exactly match the pattern we've set up for our self-healing URLs: https://example.com/posts/${POST_TITLE}-${POST_ID}. This is the URL that we want Google to index for each post. We'll do that by making sure we use this URL in our sitemap, and by adding a canonical tag to each post page.

A canonical tag looks like this:

1
<link rel="canonical" href="https://example.com/foo/bar" />

So, this is what sitemap.ts looks like:

1
import { getAllPosts, getPostSlug } from '@/utils/posts';
2
import { MetadataRoute } from 'next';
3
4
export default async function sitemap(): MetadataRoute.Sitemap {
5
const allPosts = await getAllPosts();
6
7
let urlPrefix = 'http://localhost:3000';
8
if (process.env.NODE_ENV !== 'production') {
9
urlPrefix = 'https://example.com';
10
}
11
12
return allPosts.map((post) => ({
13
url: `${urlPrefix}/posts/${getPostSlug(post)}`, // https://example.com/posts/this-is-a-post-1
14
lastModified: new Date(), // ideally, this is the last modified date of the post
15
changeFrequency: 'daily', // this will be used to determine how often pages are re-crawled
16
priority: 0.7, // the priority of this URL relative to other URLs on your site
17
}));
18
}

Now, if you run your app and visit https://localhost:3000/sitemap.xml, you should see a sitemap that includes an entry for each post, with the correct canonical URL specified.

Setting up page.tsx: where the magic happens

In the file app/posts/[slug]/page.tsx, we use the Next.js App Router's generateStaticParams function to generate URLs for all posts. This function is called at build time, and it returns an array of objects that contain the slug for each post. The slug is used to generate the URL for each post page.

Then, in the server function which renders the post page, we check whether the current URL's readable portion matches the post's actual slug. If not, we redirect to the correct URL.

We also use the Next.js App Router's generateMetadata function to generate metadata for each post. This function is called at build time, and it returns an object that contains the title and alternates for each post. The alternates object contains the canonical URL for each post, which is used to tell search engines which URL is the correct one for each post. This is critical for SEO purposes - it helps Google verify that when your page loads, the URL in the address bar matches the URL that Google has indexed for that page.

1
import { Metadata, ResolvingMetadata } from 'next';
2
import { RedirectType, notFound, redirect } from 'next/navigation';
3
import { isRedirectError } from 'next/dist/client/components/redirect';
4
5
import { Post, getAllPosts, getPostById, getPostSlug } from '@/utils/posts';
6
import { getIdFromSlug } from '@/utils/posts';
7
8
// generate URLs for all posts
9
export async function generateStaticParams() {
10
const posts = await getAllPosts();
11
12
return posts.map((post) => {
13
const slug = getPostSlug(post);
14
15
return {
16
slug,
17
};
18
});
19
}
20
21
type PostPageParams = {
22
params: {
23
slug: string;
24
};
25
};
26
27
export default async function Post({ params }: PostPageParams) {
28
const id = getIdFromSlug(params.slug);
29
30
let post: Post;
31
try {
32
post = await getPostById(id);
33
const correctSlug = getPostSlug(post);
34
35
// check whether the current URL's readable portion matches the post's actual slug
36
if (correctSlug !== params.slug) {
37
// if not, redirect to the correct URL
38
const redirectUrl = `/posts/${correctSlug}`;
39
await redirect(redirectUrl, RedirectType.replace);
40
}
41
} catch (e) {
42
// this is a hack to make redirects work from within a try/catch block
43
// shout out to @jeengbe on github for the tip
44
// https://github.com/vercel/next.js/issues/49298#issuecomment-1537433377
45
if (isRedirectError(e)) {
46
throw e;
47
}
48
49
// if the post doesn't exist, return the "not found" 404 page
50
notFound();
51
}
52
53
// make your post look nice IRL
54
return <div>My Post: {params.slug}</div>;
55
}
56
57
export async function generateMetadata(
58
{ params }: PostPageParams,
59
parent: ResolvingMetadata,
60
): Promise<Metadata> {
61
// read route params
62
const id = getIdFromSlug(params.slug);
63
// fetch data
64
const post = await getPostById(id);
65
66
return {
67
title: post.title,
68
alternates: {
69
canonical: `https://example.com/posts/${params.slug}`,
70
},
71
};
72
}

Another important note - entire component is rendered server-side - visitors to this page won't see any content flash on screen if they enter an incorrect URL. This is huge for page performance and SEO purposes, and has traditionally been challenging while using Static Site Generation.

Instead of a screen flash, your users get a great experience: they can try to load an incorrect URL, and as long as the URL ends with a hyphen followed by the id, they'll be redirected to the correct URL seamlessly, like magic. If the URL doesn't match the pattern, they'll be redirected to the 404 page.

Not Found: a 404 page for posts that don't exist

In app/not-found.tsx, create a 404 page that will render if someone tries to visit a post page with an id that doesn't exist in the url. Next provides a notFound function that will return a 404 page. We used this in page.tsx to return a 404 page if the post doesn't exist. You'll want to make this page look nice and include some helpful text, but for the sake of this example, we'll keep it simple:

1
/*
2
redirect to this page by calling the `notFound` function from the App Router in a page's server function:
3
import { notFound } from 'next/navigation';
4
*/
5
6
export default function NotFound() {
7
return <div>Not Found</div>;
8
}

Try it out!

That should do it! run yarn dev to start the server, and navigate to http://localhost:3000/posts/1 - you'll see it automatically redirect to a better URL.

A demo of a self-healing URL made in Next.js

Nice!

Head over to http://localhost:3000/sitemap.xml and check out the nicely formatted sitemap, with the correct canonical URL for each post. Now your web app, your readers, and Google are all on the same page. šŸ”„

To do: update your redirects when a post's title changes

To be completely thorough there's one more thing we should probably do: if a post's title changes, you should add an entry to your redirects file to redirect the old URL to the new one. Even though this is a self-healing URL, it's still a good idea to add a redirect for the old URL, just in case someone has bookmarked it, or if someone has shared it on social media. This will also help Google understand that the old URL is no longer the correct one, and that it should index the new URL instead.

This is a bit more challenging, but it's worth it.

Because making this work is dependent on your specific setup, I'm not going to include a code example here, but I'll give you a few pointers:

  • In your CMS, when a post is saved, check whether the title has changed. If it has, add an entry to your redirects file to redirect the old URL to the new one.
  • It's a good to make sure that you don't accidentally create a redirect loop. If the new URL for a given path is the same as any of the prior URLs, don't add a new redirect, and remove any existing redirects for that path.
  • You may need to kick off a rebuild of your site to make sure that the new redirect is picked up by your app. If you're using Vercel, you can use their API to trigger a rebuild when a post is saved.

Summary: self-healing URLs in Next.js

In this post, we learned how to set up self-healing URLs in Next.js. We used the App Router to generate URLs for all posts, and we used the App Router's generateMetadata function to generate metadata for each post. This function is called at build time, and it returns an object that contains the title and alternates for each post. The alternates object contains the canonical URL for each post, which is used to tell search engines which URL is the correct one for each post. This is great for SEO purposes - it helps Google verify that when your page loads, the URL in the address bar matches the URL that Google has indexed for that page. It also has usability benefits: if your post's title ever changes, the URL will magically update itself to match the new title. Additionally, people who visit your page while using a screen reader will have a better experience, because the URL will always match the content on the page.

Not too shabby!

Get the code

You can find the code for this tutorial on GitHub at mbifulco/next-self-healing-urls-app-router. If you enjoyed this post, please consider giving it a star ā­ļø on GitHub!

PS: What about using the Next.js Pages router?

This post started as a tutorial for using the Next.js Pages router to create self-healing URLs, but I ran into a few issues that would make it really challenging to recommend going down this path if you're using the pages router (which is actually what mikebifulco.com uses!). For that reason, I think this is a feature you should only add if you're using the App Router. I'm including the draft of my original post here in a gist in case anyone wants to take a stab at it. If you figure it out, please let me know!

Hero
How to set up self-healing URLs in Next.js for better SEO

Set up self-healing URLs with the App Router in Next.js for better SEO, accessibility, and usability

nextjsseotypescript
***

SHIP PRODUCTS
THAT MATTER

šŸ’Œ Tiny Improvements: my weekly newsletter sharing one small yet impactful idea for product builders, startup founders, and indiehackers.

It's your cheat code for building products your customers will love. Learn from the CTO of a Y Combinator-backed startup, with past experience at Google, Stripe, and Microsoft.

    Join the other product builders, and start shipping today!