How to build URLs and handle redirects in Next.js

Is your Next.js site getting larger, and URLs are getting out of hand? How can you ensure URL consistency, observe editors’ changes and issue proper redirects if URL slugs change?

Ondrej Polesny

Published on Sep 14, 2022

In my previous article, I explained how URL building works and why it’s important to preserve old URLs when they change. I also introduced a custom element for Kontent.ai that you can directly plug into your project and track all URL slug changes. However, the last bit of the proposed solution does not work in some cases in the newest version of Next.js, so in this article, I will show you a more robust and functional way of handling URLs and redirects for all rendering modes of Next.js.

The future-proof way of handling URLs

There are three components to the successful handling of URLs on any website project:

  • Building URLs to content in the website implementation
  • Observing URL slug changes
  • Implementing a robust redirection mechanism

Building URLs to content in the website implementation

A good content platform allows you to link content items without using URLs. Just like the link at the top of this article is not defined by using a URL but links to the other content item directly.

Kontent.ai

Not using URLs directly helps a lot with consistency as the platform can warn an editor when they’re about to delete a content item other items reference, but also ensures usable linking of items among multiple channels.

URLs are tightly coupled with the website implementation and type of content:

Website homeType of contentContent item
https://kontent.ai/blog/how-to-handle-url-redirects-with-headless-cms

The consistency of URLs is a vital component of any website, and as such, it should be centralized to minimize the potential for editor and developer errors. For example, this is how we handle it:

export const urlsMap = [
        ...
        {
		contentTypeCodename: contentTypes.blog_page.codename,
		urlPath: `/blog/`,
	},
	{
		contentTypeCodename: contentTypes.blog_post.codename,
		urlPath: `/blog/${SLUG_PLACEHOLDER}/`,
	},
	{
		contentTypeCodename: contentTypes.blog_topic.codename,
		urlPath: `/blog/?topic=${SLUG_PLACEHOLDER}`,
	},
        ...
]

We use this map of URL structure to generate any URL to any content with no exceptions:

export function getUrlPathByContentType(type: string, slug: string = ''): string {
	// find map record
	const mapRecord = urlsMap.find(record => record.contentTypeCodename === type)
	if (!mapRecord) {
		throw new Error(`Unable to find URL map record of type '${type}'`)
	}

	// replace slug placeholder with slug
	const urlPath = mapRecord?.urlPath.replace(SLUG_PLACEHOLDER, slug)
	return urlPath
}

This has multiple benefits:

  • You can’t experience situations when a URL to a blog post is correct on one page but is not working on another.
  • You quickly find missing URL structures as the build fails with a meaningful message in that case.
  • The structure of the content (how an editor thinks about content and builds it) is not directly dependent on URL structure (how content is displayed on the website) which is a major benefit on larger sites and the basic concept of headless architecture.
  • You easily find unused content types, which helps to keep your project organized.

Observing URL slug changes

When an editor creates and publishes a page, the search engines index the URL, and from that moment on, we must ensure it always exists. I explained the problem in detail in my previous article, so here I’ll just summarize the solution.

In Kontent.ai, you can use a custom element for keeping track of URL slug changes:

Kontent.ai

It watches over the URL slug field, and when it gets changed, it automatically stores the previous value. In code, you get both the current URL slug and the history:

{
    elements: {
        urlSlug: {
            value: 'current-slug',
            ...
        },
        urlSlugHistory: {
            value: ['original-slug', ...]
            ...
        },
        ...
    },
    ...
}

Implementing a robust redirection mechanism

Now that we get all the information from the content platform, we can switch to the Next.js implementation.

Here, the problem is that handling the redirect on the page level is not working for pre-built pages. Let’s take blog posts as an example:


Current URL slugHistory
Blog post 1blog-post-1
Blog post 2blog-post-2shiny-blog-post

You see, before building the site, we have the knowledge of all the slugs of all the blog posts, including the history. Therefore, we want to use the fallback: false mode, which instructs Next.js to pre-build everything. We hand over all the slugs, including the history, to the getStaticPaths method as that defines the existing paths:

URL slug
blog-post-1
blog-post-2
shiny-blog-post

So far so good. But when we get to getStaticProps, we need to handle the redirect:

URL slugRender a page?
blog-post-1yes
blog-post-2yes
shiny-blog-postno -> redirect to blog-post-2

We can use this code to redirect from getStaticProps to another page:

return {
    redirect: {
        permanent: true,
        destination: '/blog/blog-post-2',
    },
}

But because we’re using the fallback: false mode, all pages in the blog are being resolved during build time, and we’ll get the following error:

Error: 'redirect' can not be returned from getStaticProps during prerendering

The reason is that Next.js does not allow prerendering pages that just redirect somewhere. The best practice is to handle these redirects in next.config.js.

Solution 1: Switch mode to fallback: Blocking

You can solve this problem by switching the mode to fallback: 'blocking’, which accepts any URL slug and attempts to render a page during runtime, but it comes with three disadvantages:

  • You need to exclude the history URL slugs from getStaticPaths so that Next.js won’t try to render them during pre-building.
  • You need to handle 404 cases, so your getStaticProps implementation must first verify the URL slug to see if a page should exist at all.
  • The page resolution for all slugs except the ones provided in getStaticPaths happens at runtime.

This can be a nice workaround for small to medium size websites but not an ultimate solution to the problem.

Solution 2 (Better): Pre-build the redirects

If we know all the historic slugs before running a build, why waste runtime resources on resolving them?

Next.js has a redirects mechanism; the only problem is that it requires the list of URLs in next.config.js prior to running a build:

// code piece from https://nextjs.org/docs/api-reference/next.config.js/redirects
module.exports = {
  async redirects() {
    return [
      {
        source: '/about',
        destination: '/',
        permanent: true,
      },
    ]
  },
}

Fortunately, the redirects section is a function, so we can pre-build all the URLs into a JSON file and load it in the config. We’ll do this in five steps:

  • Add a pre-build script
  • Load historic URL slugs
  • Generate URLs in the structure Next.js requires
  • Save all in a JSON file /static/redirects.json
  • Load the file in next.config.js
Kontent.ai

I described the pre-build scripts in another article, so here we’ll just focus on adding the actual functionality.

First, we’ll load the historic URL slugs. Because we’re generating the redirects file for the whole site, we need to handle all used content types here. This is tricky as the codenames of relevant elements may vary among content types. The best practice is to keep the URL slug and URL slug history elements’ codenames consistent among your content model - we use url_slug and url_slug_history, respectively. In that case, we can fetch the data using a single Kontent.ai query:

const client = new DeliveryClient({
	projectId: params.env.KONTENT_PROJECT_ID,
})

const res = await client
	.items()
	.elementsParameter(['url_slug', 'url_slug_history'])
	.notEmptyFilter('elements.url_slug_history')
	.toAllPromise()

Note: You can adjust codenames of both content types and their elements either in the platform UI or via API

Then, we need to transform the data into the Next.js accepted structure:

const data = res.data.items.map(item =>
	JSON.parse(item.elements.url_slug_history.value).map(historySlug => ({
		source: getUrlPathByContentType(item.system.type, historySlug),
		destination: getUrlPathByContentType(item.system.type, item.elements.url_slug.value),
		permanent: true,
	}))
)

Note that we need to JSON.parse the element value as it’s a JSON encoded array. Then, depending on the used URL building mechanism, we need to build the source and destination URLs from the slugs. On our site, we’re using the function displayed at the beginning of this article – getUrlPathByContentType – which finds the relevant record in the URLs map by content type.

Then, we save the list to a physical file:

const fullFilePath = path.join(process.cwd(), 'static', 'redirects.json')
await fs.promises.writeFile(fullFilePath, JSON.stringify(data.flat()))

This is the full pre-build script code for reference:

import { DeliveryClient } from '@kontent-ai/delivery-sdk'
import path from 'path'
import fs from 'fs'
import { getUrlPathByContentType } from '../../utils/urlUtils'
import { IScriptParams } from '../interfaces/IScriptParams'

export default async function execute(params: IScriptParams) {
	const fullFilePath = path.join(process.cwd(), 'static', 'redirects.json')

	// remove the old file
	if (fs.existsSync(fullFilePath)) {
		await fs.promises.unlink(fullFilePath)
	}

	const client = new DeliveryClient({
		projectId: params.env.KONTENT_PROJECT_ID,
	})

	const res = await client
		.items()
		.elementsParameter(['url_slug', 'url_slug_history'])
		.notEmptyFilter('elements.url_slug_history')
		.toPromise()

	const data = res.data.items.map(item =>
		JSON.parse(item.elements.url_slug_history.value).map(historySlug => ({
			source: getUrlPathByContentType(item.system.type, historySlug),
			destination: getUrlPathByContentType(item.system.type, item.elements.url_slug.value),
			permanent: true,
		}))
	)

	await fs.promises.writeFile(fullFilePath, JSON.stringify(data.flat()))
}

When you run the pre-build script, it fetches the old URL slugs and generates the /static/redirects.json file that looks like this:

[
	{
		"source": "/blog/shiny-blog-post/",
		"destination": "/blog/blog-post-1/",
		"permanent": true
	}
]

Finally, we implement the redirects() function to load that JSON file in the next.config.js:

const nextConfig = {
	...
	async redirects() {
		const fullFilePath = path.join(process.cwd(), 'static', 'redirects.json')
		if (!fs.existsSync(fullFilePath)) {
			return []
		}
		const redirectsFileData = await fs.promises.readFile(fullFilePath)
		return JSON.parse(redirectsFileData)
	},
}

When you run the next build, Next.js will load the redirects from the /static/redirects.json file and issue proper redirects on runtime.

Conclusion

In this article, I explained the three components of future-proof URL handling on large Next.js sites. I outlined the problems with redirects on pre-generated sites and provided a robust solution for handling redirects in an automated way using pre-build scripts. With this code in place, your application is ready for URL slug changes and ensures your pages keep their ranks.

If you’re building a Next.js site with Kontent.ai, join our community on Discord to discuss your experience and get help from fellow developers (including me :-)).

Subscribe to the Kontent.ai newsletter

Get the hottest updates while they’re fresh! For more industry insights, follow our LinkedIn profile.