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.