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.