SEO-Friendly Routing — Slugs, Canonical URLs and Redirects

SEO-friendly URLs are clean, descriptive, and stable. /blog/2025/building-rest-apis-with-aspnet is more discoverable and linkable than /posts/details/42. But implementing SEO-friendly routing requires more than a pretty URL pattern — you need canonical URLs (to tell search engines which URL is authoritative), 301 redirects for changed URLs (to preserve link equity), and consistency in URL case and trailing slashes. These patterns are production requirements for any public-facing ASP.NET Core application.

Slug-Based Routes and Canonical URLs

// ── Slug-based routing with year/month segments ───────────────────────────
app.MapControllerRoute(
    name:     "blog-post",
    pattern:  "blog/{year:int:range(2000,2099)}/{month:int:range(1,12)}/{slug}",
    defaults: new { controller = "Blog", action = "Post" });

app.MapControllerRoute(
    name:    "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

// ── Blog controller with canonical URL header ─────────────────────────────
[Route("blog")]
public class BlogController : Controller
{
    [HttpGet("{year:int}/{month:int}/{slug}")]
    public async Task<IActionResult> Post(int year, int month, string slug)
    {
        var post = await _service.GetBySlugAsync(slug);
        if (post is null) return NotFound();

        // Verify the year/month in the URL match the post's actual published date
        if (post.PublishedAt?.Year != year || post.PublishedAt?.Month != month)
        {
            // Redirect to the canonical URL (correct year/month)
            return RedirectToRoutePermanent("blog-post", new
            {
                year  = post.PublishedAt!.Value.Year,
                month = post.PublishedAt.Value.Month,
                slug  = post.Slug,
            });
        }

        // Add canonical link header for SEO
        var canonicalUrl = Url.RouteUrl("blog-post",
            new { year, month, slug },
            protocol: "https",
            host:     Request.Host.Value);

        Response.Headers["Link"] = $"<{canonicalUrl}>; rel=\"canonical\"";

        return View(post);
    }
}
Note: A canonical URL tells search engines “this is the authoritative URL for this content” — any other URL serving the same content should redirect to it. This prevents duplicate content penalties. Add it as an HTTP Link header (Link: <https://site.com/canonical>; rel="canonical") or as an HTML <link rel="canonical" href="..."> tag in the view. Both approaches are valid; the HTTP header is more reliable (works for non-HTML responses too).
Tip: Enforce lowercase URLs globally to prevent duplicate content from mixed-case URLs. Configure the route options: builder.Services.Configure<RouteOptions>(opts => { opts.LowercaseUrls = true; opts.LowercaseQueryStrings = true; opts.AppendTrailingSlash = false; }). With these settings, /Posts/Details/42 and /posts/details/42 generate the same lowercase URL. Without lowercase enforcement, search engines may index both the uppercase and lowercase versions as separate pages.
Warning: Use RedirectToActionPermanent() (HTTP 301) for URL changes that are permanent — changing a route structure, renaming a slug, or fixing a typo in a URL. Use RedirectToAction() (HTTP 302) for temporary redirects — when you know the URL will change back, or when redirecting after form submission (PRG pattern). Search engines cache 301 redirects for a long time; if you issue a 301 then change the destination, many users’ browsers will use the cached redirect and never request the new destination.

Old URL Redirect Middleware

// ── Redirect old URL format to new canonical format ────────────────────────
// Old: /posts/42  →  New: /blog/2025/01/my-post-title

app.Use(async (context, next) =>
{
    // Match old ID-based URL pattern
    var path = context.Request.Path.Value ?? "";
    var match = System.Text.RegularExpressions.Regex.Match(
        path, @"^/posts/(\d+)$");

    if (match.Success && int.TryParse(match.Groups[1].Value, out int postId))
    {
        // Look up the canonical URL from the database (or cache)
        var service = context.RequestServices.GetRequiredService<IPostService>();
        var post    = await service.GetByIdAsync(postId);

        if (post?.PublishedAt is not null)
        {
            var newUrl = $"/blog/{post.PublishedAt.Value.Year}/" +
                         $"{post.PublishedAt.Value.Month:D2}/{post.Slug}";
            context.Response.StatusCode  = StatusCodes.Status301MovedPermanently;
            context.Response.Headers["Location"] = newUrl;
            return;
        }
    }
    await next(context);
});

// ── Lowercase URL enforcement via RouteOptions ─────────────────────────────
builder.Services.Configure<RouteOptions>(opts =>
{
    opts.LowercaseUrls          = true;
    opts.LowercaseQueryStrings  = true;
    opts.AppendTrailingSlash    = false;
});

Common Mistakes

Mistake 1 — Using 302 redirect for permanent URL changes (search engines do not update index)

❌ Wrong — search engines continue indexing the old URL; link equity not transferred:

return RedirectToAction("Post", new { slug });  // 302 — temporary, not permanent!

✅ Correct — use RedirectToActionPermanent() or RedirectToRoutePermanent() for URL migrations.

Mistake 2 — Not enforcing lowercase URLs (duplicate content in search index)

❌ Wrong — /Posts/Details and /posts/details both return 200; search engines index both as separate pages.

✅ Correct — configure opts.LowercaseUrls = true in RouteOptions and add redirect middleware for uppercase URLs.

🧠 Test Yourself

You change a post’s URL structure from /posts/{id} to /blog/{year}/{slug} and issue a 301 redirect from old to new. Six months later you change the URL again to /articles/{slug} with another 301. What do users with the original URL still bookmarked experience?