Photo of a person walking towards an arrow.
Photo of a person walking towards an arrow.

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:

slug.ts
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.

/posts/[...id]/page.tsx
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}
Info

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.