Implementing Notion style URLs
I hate unrecognizable URLs. If I want to go back to a specific document or page, I should be able
to type the name of it in the address bar and find it in my browser history. Notion is particularly good at this,
and v0 was particularly bad at it (for a while), so this is what I did to fix v0.
Here's what a notion URL looks like:
https://notion.so/org/Team-Roadmap-159f06b059c491d9abb8
https://notion.so/org/Team-Roadmap-159f06b059c491d9abb8
It's recognizable, visually appealing, and easy to find in your browser history:
I'm a fan.
At first I thought implementing it would be straight forward, but there are a few easy-to-hit gotcha's involved.
This post will be focused on Next.js, but the concepts are universal. Here are some of the things to keep in mind:
- You need to ensure the canonical URL is up-to-date
- The client needs to wind up on the most recent URL, otherwise crawlers like Google can index "fake" paths
- Be mindful of your previous URLs and ensure they don't conflict with the logic for adding/parsing the slug
To get started, we need two functions: one to generate the URL, and one to parse it.
It's important we support URLs both with and without the title, and I recommend using a library to handle
the slugification so you don't need to worry about things like special characters.
function extractIdFromSlug(slug: string): {
id: string
title?: string
} {
const lastHyphenIndex = slug.lastIndexOf('-')
// If no hyphen found, its just the ID
if (lastHyphenIndex === -1) {
return { id: slug, title: undefined }
}
const id = slug.substring(lastHyphenIndex + 1)
const title = slug.substring(0, lastHyphenIndex)
return { id, title: title || undefined }
}
function extractIdFromSlug(slug: string): {
id: string
title?: string
} {
const lastHyphenIndex = slug.lastIndexOf('-')
// If no hyphen found, its just the ID
if (lastHyphenIndex === -1) {
return { id: slug, title: undefined }
}
const id = slug.substring(lastHyphenIndex + 1)
const title = slug.substring(0, lastHyphenIndex)
return { id, title: title || undefined }
}
function getSluggifiedId(id: string, title: string | undefined) {
if (title) {
/* you can use slugify or some other library */
return `${slugify(title.slice(0, 50))}-${id}`
}
return id
}
function getSluggifiedId(id: string, title: string | undefined) {
if (title) {
/* you can use slugify or some other library */
return `${slugify(title.slice(0, 50))}-${id}`
}
return id
}
(In practice, I had to add support for passing in a minimum ID length for each case, because v0 used to generate IDs with dashes.
)
Now, integrating these utilities into our pages requires:
- Updating the canonical URL
- Implementing a way to redirect users to the updated URL.
- If you do this on the server, you can serve a 301 redirect
Using the Next.js Metadata API:
export async function generateMetadata(): Promise<Metadata> {
return {
// ...other fields here...
alternates: {
canonical: `/chat/${getSluggifiedId(chatId, title)}`,
},
}
}
export async function generateMetadata(): Promise<Metadata> {
return {
// ...other fields here...
alternates: {
canonical: `/chat/${getSluggifiedId(chatId, title)}`,
},
}
}
This ensures the pages Metadata is correct for SSR (be sure to revalidate the path if you update the title),
but the client can still be on the wrong page if they visit an old URL.
To solve that, we can introduce a client component that we'll include in our root layout (or wherever you need it):
"use client"
// throw this in your layout/page
export function KeepClientOnUpToDateSlugPath({
id,
title,
}: {
id: string
title?: string
}) {
useLayoutEffect(() => {
const slug = getSluggifiedId(id, title)
const url = new URL(window.location.href)
const pathParts = url.pathname.split('/')
const lastPart = pathParts[pathParts.length - 1]
if (lastPart && lastPart !== slug) {
// Replace last path segment
pathParts[pathParts.length - 1] = slug
url.pathname = pathParts.join('/')
window.history.replaceState(null, '', url.toString())
}
}, [id, title])
return null
}
"use client"
// throw this in your layout/page
export function KeepClientOnUpToDateSlugPath({
id,
title,
}: {
id: string
title?: string
}) {
useLayoutEffect(() => {
const slug = getSluggifiedId(id, title)
const url = new URL(window.location.href)
const pathParts = url.pathname.split('/')
const lastPart = pathParts[pathParts.length - 1]
if (lastPart && lastPart !== slug) {
// Replace last path segment
pathParts[pathParts.length - 1] = slug
url.pathname = pathParts.join('/')
window.history.replaceState(null, '', url.toString())
}
}, [id, title])
return null
}
Fin! Now I have great URLs like https://v0.dev/chat/autoplay-slideshow-for-v0-Z7canzuZ4b9