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);
}
}
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).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.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.