URLs should be designed to be shared. While building this blog engine, I was so focused on avoiding collisions between posts using uuid4 through nanoid, I forgot that links wouldn't have any meaning the the user. Fortunately I've seen a pattern with other small sites like Reddit.com or Etsy.com: include a URL friendly version of the title along with the regular page ID.
From https://example.com/watch/123↗ , you end up with https://example.com/watch/123/never-gonna-give-you-up↗ .
Let's fix it!
Converting Titles to Slugs
Creating a slug from the title is mainly done by converting characters to be URL safe, thus replacing or removing incompatible ones. Using encodeURIComponent↗ would be safe but not user friendly. á â à ä
encodeURIComponent encoded would become %C3%A1%20%C3%A2%20%C3%A0%20%C3%A4
, but a-a-a-a
is a better slug.
If you want an out of the box solution that works with many languages, using an npm library↗ like slugify↗ might be best. I only need to support Latin alphabet↗ based languages for now, a few line of codes will suffice:
export function generateSlug(str: string): string {
return (
str
// remove end whitespaces
.trim()
// convert space(s) to dash (-)
.replace(/[\s]+/gi, "-")
// break down accented characters and convert to latin letters (ex: ä -> a)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
// convert all remaining incompatible letters to underscore (_)
.replace(/[^a-z0-9\-]/gi, "_")
.toLowerCase()
);
}
// generateSlug("Hello World!") -> "hello-world_" - punctuation is dropped
// generateSlug("Bon appétit") -> "bon-appetit" - accentuated letters converted
// generateSlug("猫") -> "_" - 😿
Good enough!
Redirecting with Next.js
The slug should be an optional part of the URL: posts/{id}(/{slug})
. If the user tries to access the post without the slug, or with an incorrect slug, I will redirect them to the full URL.
Beyond dynamic routes, Next.js allows for catch all statements↗ with the [...folderName]
syntax.
Also it allows for redirects and error pages to be served in the initial generateMetadata
step↗, before the page is rendered.
export async function generateMetadata({params: {id}}: {id: string[]}): Promise<Metadata | undefined> {
// split the id and slug from the URL params
const postId = id[0];
const postTitleSlug = id[1] || "";
let data: PostData | null = null;
// fetch the post
try {
data = await getPost(api, postId);
} catch (error) {
notFound();
}
if (!data) {
notFound();
}
// generate the slug and redirect if the user isn't at the right URL
const urlTitleSlug = generateSlug(data.post.title);
if (postTitleSlug !== urlTitleSlug) {
redirect(`/posts/${postId}/${urlTitleSlug}`, RedirectType.replace);
}
return {
title: data.post.title,
description: data.post.description,
};
}
export default async function(){
// render page
}
Next.js uses exceptions to handle notFound()
and redirect()
.
The title is now part of the URL!
Observations
A small negative is that a malicious user could change the slug to something else and share it to misdirect others.
The process is backward and forward compatible: if links have been shared without the slug or if the title is changed, the user will be redirected to the right place.