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!
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 npm2npx create-next-app34# or, with yarn5yarn create next-app67# or, with pnpm8pnpm 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-router2ā Would you like to use TypeScript? Yes3ā Would you like to use ESLint? Yes4ā Would you like to use Tailwind CSS? Yes5ā Would you like to use `src/` directory? Yes6ā Would you like to use App Router? (recommended) Yes7ā 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:
1npm install slugify23# or, with yarn4yarn add slugify56# or, with pnpm7pnpm 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:
1app2āāā posts3ā āāā [slug] // note that this is a _folder_ called [slug]. This is how dynamic routes are set up in the app router4ā āāā page.tsx // render one post5āāā not-found.tsx // this will render if someone tries to visit a post page with an id that doesn't exist in the url6āāā sitemap.ts // generate a dynamic sitemap with the _correct_ canonical URL for each post7āāā utils8āāā 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.
1import slugify from 'slugify';23export type Post = {4userId: number;5id: number;6title: string;7content: string;8};910export const getAllPosts = async () => {11// fetch all posts from an example API.12// this is just a placeholder, replace with your own data13// shout out to https://jsonplaceholder.typicode.com/ - what a great service!14const postsResponse = await fetch(15'https://jsonplaceholder.typicode.com/posts',16);17const posts = (await postsResponse.json()) as Post[];1819return posts;20};2122export const getPostById = async (id?: string | number) => {23if (!id) {24throw new Error('No ID provided');25}2627if (typeof id === 'string') {28id = parseInt(id);29}3031const allPosts = await getAllPosts();3233const post = allPosts.find((post) => post.id === id);3435if (!post) {36// we'll use a try/catch block around this function later37// to redirect if a post doesn't exist with this id,38// so make sure to throw an error here.39throw new Error('Post not found');40}4142return post;43};4445/**46* Converts input to a URL-safe slug47* @param {string} title - the title of the post48* @returns {string} a URL-safe slug based on the post's title49*/50const titleToSlug = (title: string) => {51const uriSlug = slugify(title, {52lower: true, // convert everything to lower case53trim: true, // remove leading and trailing spaces54});5556// encode special characters like spaces and quotes to be URL-safe57return encodeURI(uriSlug);58};5960// given a post, return its slug61export const getPostSlug = (post: Post) => {62return `${titleToSlug(post.title)}-${post.id}`;63};6465// given any slug, try to extract an id from it66export 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:
1import { getAllPosts, getPostSlug } from '@/utils/posts';2import { MetadataRoute } from 'next';34export default async function sitemap(): MetadataRoute.Sitemap {5const allPosts = await getAllPosts();67let urlPrefix = 'http://localhost:3000';8if (process.env.NODE_ENV !== 'production') {9urlPrefix = 'https://example.com';10}1112return allPosts.map((post) => ({13url: `${urlPrefix}/posts/${getPostSlug(post)}`, // https://example.com/posts/this-is-a-post-114lastModified: new Date(), // ideally, this is the last modified date of the post15changeFrequency: 'daily', // this will be used to determine how often pages are re-crawled16priority: 0.7, // the priority of this URL relative to other URLs on your site17}));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.
1import { Metadata, ResolvingMetadata } from 'next';2import { RedirectType, notFound, redirect } from 'next/navigation';3import { isRedirectError } from 'next/dist/client/components/redirect';45import { Post, getAllPosts, getPostById, getPostSlug } from '@/utils/posts';6import { getIdFromSlug } from '@/utils/posts';78// generate URLs for all posts9export async function generateStaticParams() {10const posts = await getAllPosts();1112return posts.map((post) => {13const slug = getPostSlug(post);1415return {16slug,17};18});19}2021type PostPageParams = {22params: {23slug: string;24};25};2627export default async function Post({ params }: PostPageParams) {28const id = getIdFromSlug(params.slug);2930let post: Post;31try {32post = await getPostById(id);33const correctSlug = getPostSlug(post);3435// check whether the current URL's readable portion matches the post's actual slug36if (correctSlug !== params.slug) {37// if not, redirect to the correct URL38const redirectUrl = `/posts/${correctSlug}`;39await redirect(redirectUrl, RedirectType.replace);40}41} catch (e) {42// this is a hack to make redirects work from within a try/catch block43// shout out to @jeengbe on github for the tip44// https://github.com/vercel/next.js/issues/49298#issuecomment-153743337745if (isRedirectError(e)) {46throw e;47}4849// if the post doesn't exist, return the "not found" 404 page50notFound();51}5253// make your post look nice IRL54return <div>My Post: {params.slug}</div>;55}5657export async function generateMetadata(58{ params }: PostPageParams,59parent: ResolvingMetadata,60): Promise<Metadata> {61// read route params62const id = getIdFromSlug(params.slug);63// fetch data64const post = await getPostById(id);6566return {67title: post.title,68alternates: {69canonical: `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/*2redirect to this page by calling the `notFound` function from the App Router in a page's server function:3import { notFound } from 'next/navigation';4*/56export default function NotFound() {7return <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.
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!