Generate SEO-friendly Permalinks that contain dynamic Slugs

2024-04-22 Peter Ullrich

We’re Indie Courses, the video course platform for indie creators. We help you sell your online courses quickly and give you the tools and knowledge to make more sales than you would elsewhere. No marketing degree needed.

The Problem

We here at Indie Courses had an issue the other day: The courses in our catalog didn’t rank well on Google despite having all SEO-required elements like an H1-title, proper Open Graph tags, and so on. Interestingly enough, the external course in our catalog ranked quite well despite having less content.

The only real difference between the internal and external course pages was how we generated their URLs. Internal course pages used the UUID of the course whereas the external pages used the slugified course title.

# Internal Course URL

# External Course URL

The Solution

After a few minutes of Googling (yes, people still do that), we learned that the URL structure has a significant impact on the SEO-friendliness of a page. We decided to add slugs to the URLs of our courses, but after some initial excitement we ran into a big problem:

If we use the course title in the URL and the creator changes the title, all previous links will break.

So, we could not assume that the slugified course title in the URL is permanent, which posed the question:

How can we create a permalink for the course if the slug is dynamic?

After a lot of head-scratching, we asked the lovely Elixir community on Twitter and promptly received a suggestion:

Store the slugs in a separate table and keep a one-to-many relationship between the course and its slugs.

The advantage of this suggestion by John, Alex, and Thibaut (Thank you ❤️) would be that old slugs are kept forever, so old links would never break even if the course title - and therefore slug - is changed.

The downside would be that we have to do a database lookup every time a visitor accesses the course page. Indie Courses runs in four regions around the globe but its database is in Sweden which means that every database lookup has a few hundred milliseconds of latency.

Another suggestion was made by Gerd who proposed to use the ID in the path followed by the slug. That’s how Stackoverflow generates their URLs:

# Example Stackoverflow link
domain + path              | ID   | Slug

We liked this suggestion, but since we use UUIDs the links would become really long. The third suggestion that came in was the charm:

Add the course UUID to the URL and ignore the slug altogether!

This suggestion was made by the fine people at, in particular Tyler and Can. Thank you! 💚

Tyler shared how Felt generates unique URLs for the maps they host. They build the URL from a dynamic part - the map title - and a permanent one - the map UUID encoded as Base62.

# Example Felt URL
path        | map title               | map UUID as Base62

Actually, when you try to decode the map ID, you will find it contains two extra characters. But the concept is still the same: Add the UUID as Base62 encoded string to the end of the URL.

The advantage of this is that we can ignore the slug that comes before the UUID when retrieving the course. Creators can change their course title and all old and new links still work because they all contain the UUID. Also, we don’t need to do a database lookup which saves us a few hundred milliseconds in latency.

At this point you might ask: What’s Base62?

Base62 is a non-official variation of Base64 that’s safe to use in URLs because it drops two characters that Base64 uses: + and /. That’s it!

The Implementation

Implementing the new URL structure was surprisingly simple. All we had to do was to replace the course UUID with a dynamically generated slug in the links of our course catalog. We used two libraries for generating the URLs:

  1. Slugy helped us to slugify the course title
  2. Base62UUID converted the course UUID to Base62 and back

The helper function for generating the URL looked like this:

def generate_path(course) do
  slug = Slugy.slugify(course.title)
  id = Base62UUID.encode!(


And this is how we convert the path back to a UUID:

defp get_course_id(id_or_slug) do
  case Ecto.UUID.cast(id_or_slug) do
    {:ok, id} -> id
    :error -> get_id_from_slug(id_or_slug)

defp get_id_from_slug(id_or_slug) do
  |> String.split("-")
  |> List.last()
  |> Base62UUID.decode()
  |> case do
    {:ok, id} -> id
    {:error, _error} -> {:error, :not_found}

You might have noticed that we first try to cast the slug to a UUID. We added this because our creators already shared links to their courses which contain the UUID format and we don’t want to break those. So, if somebody clicks on the “old” link which only has the UUID, they will still see the course.

Next, we split the slug into its parts and decode the last part from Base62 to UUID. Base62UUID will always convert the slug to a UUID, even if its length is not as expected. The encoded UUID should have 22 bytes, but any string shorter than this will be considered “valid”. The library adds zeros to the UUID if the string is shorter which can be confusing.

With these two functions in place, our new URLs now look like this:

We now have SEO-friendly permalinks to our courses that allow dynamic slugs. Thanks again to the fine folks at for helping me out!

Two Notes

If you decide to adopt this URL structure, you should be aware of two implementation details:

  1. It’s good practice to redirect from outdated or invalid slugs to the new ones.

So, if a user tries to access a course page using an outdated slug, you should fetch the latest slug and redirect the user to it. That way, the user will always see the latest URL.

Also, some users might want to add nasty words in the slug section, because it gets ignored anyway. Also in this situation, it is beneficial to redirect to the correct, non-nasty slug.

  1. You must add a canonical header to prevent duplicate indexing

Imagine that you publish a page that has slug-a in the URL, Google indexes it, then you change the slug to slug-b, and Google indexes the same page again. Google now has two pages with the same content, but different URLs.

Google really doesn’t like that and neither should you.

Luckily, the solution is simple: Add a page header like this:

<link rel="canonical" href="" />

This “canonical” link tells Google which of the two URLs is the correct one. When Google tries to re-index the old URL, it will see this link and realise that it has an outdated version of the page. Usually, it then goes and indexes the new page. Now Google is happy and are you!


And that’s it! If you enjoyed this article, consider hosting your video course with us! Also, go check out the fine work of the wonderful people at If you want to be notified of future blog posts, subscribe to your newsletter below. Until the next time!

Sign up for Product Updates and Articles