Easy to Create SEO-friendly URLs with a slug in ASP.NET Core MVC

For a little while, we have been working on a side-project now in ASP.NET Core. This web application generates URLs that have a (GUID) id in it, which is not particularly nice to look at. For this reason, we decided to have a look at how we can implement a so-called slug into the URLs. This is a friendly looking name based on text which looks a lot better and is also better for the SEO of your website. In this post, we will share with you the things we ran into.

A bit of background

The web application we have been working on is fairly simple in basics. One the one hand people can create entries, on the other hand, people can read those same entries. When saving the entries to the database a new unique id is generated in the form of a GUID. To access the same entries, someone would need to go to a link like this: https://domain.com/exp/details/g7jhuyg5-455g-gh78-gyg7-88d5gui6jhj89ub678bh. That isn’t really nice to look at and also it doesn’t really tell you anything about the contents.

If you look at this blog post, for example, you will notice the URL will have a nice short form of the posts’ title. This is also known as a slug. That is what we wanted for my web application!

Implementing a slug

Implementing this for our web application was actually pretty easy. Depending on your requirements/wishes there are a couple of ways to do it. We have seen some variations where it is implemented mainly for SEO reasons and the slug is just added after or together with the id of the entity. In our example, we would still have a URL that looks like https://domain.com/exp/details/g7jhuyg5-455g-gh78-gyg7-88d5gui6jhj89ub678bh/my-slug/. When using a simple integer as an id this is feasible. For a GUID, not so much. Therefore we just wanted the slug and no more ID. Of course, the challenge here is to generate a slug that is unique. More on this later.

Generate the slug

We figured the first thing we needed to do was be able to generate a slug. Surely we wasn’t the first person to want this. So after some Google-fu we ended up, of course, on Stackoverflow. Or at least did it a couple of years ago. Inspecting the code it seemed good enough for me. Basically what it does is remove all characters that are not a number or letter and switch spaces with dashes. You can see the full code underneath.

public static class FriendlyUrlHelper
{
public static string GetFriendlyTitle(string title, bool remapToAscii = false, int maxlength = 80)
{
if (title == null)
{
return string.Empty;
}
int length = title.Length;
bool prevdash = false;
StringBuilder stringBuilder = new StringBuilder(length);
char c;
for (int i = 0; i < length; ++i)
{
c = title[i];
if ((c >= ‘a’ && c <= ‘z’) || (c >= ‘0’ && c <= ‘9’))
{
stringBuilder.Append(c);
prevdash = false;
}
else if (c >= ‘A’ && c <= ‘Z’)
{
stringBuilder.Append((char)(c | 32));
prevdash = false;
}
else if ((c == ‘ ‘) || (c == ‘,’) || (c == ‘.’) || (c == ‘/’) ||
(c == \\) || (c == ‘-‘) || (c == ‘_’) || (c == ‘=’))
{
if (!prevdash && (stringBuilder.Length > 0))
{
stringBuilder.Append(‘-‘);
prevdash = true;
}
}
else if (c >= 128)
{
int previousLength = stringBuilder.Length;
if (remapToAscii)
{
stringBuilder.Append(RemapInternationalCharToAscii(c));
}
else
{
stringBuilder.Append(c);
}
if (previousLength != stringBuilder.Length)
{
prevdash = false;
}
}
if (i == maxlength)
{
break;
}
}
if (prevdash)
{
return stringBuilder.ToString().Substring(0, stringBuilder.Length 1);
}
else
{
return stringBuilder.ToString();
}
}
/// <summary>
/// Remaps the international character to their equivalent ASCII characters. See
/// http://meta.stackexchange.com/questions/7435/non-us-ascii-characters-dropped-from-full-profile-url/7696#7696
/// </summary>
/// <param name=character>The character to remap to its ASCII equivalent.</param>
/// <returns>The remapped character</returns>
private static string RemapInternationalCharToAscii(char character)
{
string s = character.ToString().ToLowerInvariant();
if (àåáâäãåąā.Contains(s))
{
return a;
}
else if (èéêëę.Contains(s))
{
return e;
}
else if (ìíîïı.Contains(s))
{
return i;
}
else if (òóôõöøőð.Contains(s))
{
return o;
}
else if (ùúûüŭů.Contains(s))
{
return u;
}
else if (çćčĉ.Contains(s))
{
return c;
}
else if (żźž.Contains(s))
{
return z;
}
else if (śşšŝ.Contains(s))
{
return s;
}
else if (ñń.Contains(s))
{
return n;
}
else if (ýÿ.Contains(s))
{
return y;
}
else if (ğĝ.Contains(s))
{
return g;
}
else if (character == ‘ř’)
{
return r;
}
else if (character == ‘ł’)
{
return l;
}
else if (character == ‘đ’)
{
return d;
}
else if (character == ‘ß’)
{
return ss;
}
else if (character == ‘Þ’)
{
return th;
}
else if (character == ‘ĥ’)
{
return h;
}
else if (character == ‘ĵ’)
{
return j;
}
else
{
return string.Empty;
}
}
}

For such a simple task this seems like a lot of code, but if you take a better look at it, you will realize that there are a lot of edge cases in here that are accounted for.

With this method, we can simply generate a slug from the title of our entity and save that in a new column. Underneath you can see the piece of code that does this for us.

var cfpToAddSlug = FriendlyUrlHelper.GetFriendlyTitle(submittedCfp.EventTitle);//SlugHelper.Slugify(submittedCfp.EventTitle);
var i = 0;
// Prevent duplicate slugs
while (_cfpContext.Cfps.Any(cfp => cfp.Slug == cfpToAddSlug))
{
cfpToAddSlug = $”{cfpToAddSlug}-{++i};
}

Because we only want to navigate based on slugs, we need to ensure that my slugs are unique. To do this we simply check if the generate slug is already in the database and simply append a number. There are probably better ways to do this, but for now, we will just see how often a duplicate is generated and deal with it then. Next step: updated our links and routes to be able to deal with the slug.

Navigating based on the slug

This is where we got a bit confused. Ideally, we wanted both: reaching the page by the GUID and by its slug. Because the website has been up for a couple of weeks, there are already some links circulating and we didn’t want these to break. Again, ideally, we didn’t want to go through each line of code where a link was generated. So, first we tried to create another route which handled the slug, next to the route that was already in place. These had these signatures:

We hoped that the system would figure out which route to take. We mean, they are different types, right?! Turns out we were wrong, or at least: we were doing something wrong.

We don’t remember what happened when, but we ended up just one of the details methods being hit in either case. Meaning that in one case our GUID was empty which caused trouble or in the other case our GUID was a string which caused problems as well.

Then we decided to do things differently. We took the old Details method with the GUID parameter, changed that to a string and then implemented some logic to assume that what the input parameter contained a slug. If not, check if it’s a GUID and go the old route. What we ended up with, is this:

public IActionResult Details(string id)
{
if (string.IsNullOrWhiteSpace(id))
return RedirectToAction(index, home);
var selectedCfp = _cfpContext.Cfps.SingleOrDefault(cfp => cfp.Slug == id);
if (selectedCfp == null)
{
// Check it the id happens to be a Guid
if (Guid.TryParse(id, out Guid guidId))
{
if (guidId != Guid.Empty)
selectedCfp = _cfpContext.Cfps.SingleOrDefault(cfp => cfp.Id == guidId);
}
if (selectedCfp == null)
// TODO to error page?
return RedirectToAction(index, home);
}
// … Non relevant code
return View(selectedCfp);
}

Check if we can find an entity with the string value if not, it is possibly a GUID. Check that. In case we still can’t find anything we just redirect to the homepage. In other cases when we do find a result, return that and show the requested result. Now, this navigation is fixed. We could reach our entities from both the GUID as well as the slug on the same base URL. The last thing we wanted to do is now generate the links on our web application to be links with the slugs.

Generating links with a slug

As mentioned before, we wanted to do this without having to go through all the places a link was generated. So, we tried to create our own UrlHelper, which somewhat worked, but decided, in the end, it had too much-hardcoded routes and deviated from the default implementation too much. And thus, we ended up going through the links one by one. Luckily there weren’t too many places.

To also stick with the default ASP.NET MVC routing we named the slug id as you can see in the Details method above. To fully understand the why or how, let us show you how we generate links in our Razor pages.

<a href=@(Url.Action(details, cfp, new { id = Model.NewestCfp.Slug }))>
<img src=@Model.NewestCfp.EventImage alt=Event image style=object-fit:cover;>
</a>

In the above code, you see an example of how we generate a slug link. Before, where Model.NewestExp.Slug is, it said Model.NewestExp.Id, causing the GUID to be inserted there. At our first attempt, we changed that whole line to this: @(Url.Action(detailscfp, new { slug = Model.NewestExp.Slug })). Note that it says slug = instead of id =. This caused our links to look this: https://domain.com/exp/details/?slug=my-slug-here. While this did work, it still didn’t look that great. We could also have changed the route in the MVC routing engine to work with slug but we figured changing the parameter name to id would be easier. And since we are using the slugs as an ID now as well, it ain’t too bad. Now, our links look great: https://domain.com/exp/details/easy-to-create-seo-friendly-urls.

Wrapping up

Implementing this slug functionality didn’t take as much time in the end. We can imagine that the actual implementation can vary depending on your situation. And it is probably easier if you think of this upfront. Because we implemented this afterward, and if you are doing so as well, don’t forget that you need to update your current entries with a slug as well! Or take into account that an entry can do without it.

Anjali Punjab

Anjali Punjab is a freelance writer, blogger, and ghostwriter who develops high-quality content for businesses. She is also a HubSpot Inbound Marketing Certified and Google Analytics Qualified Professional.