Hard-coded URLs in controllers and views break silently when routes change. The routing system provides URL generation APIs that produce correct URLs from controller/action names and route values — no strings. Url.Action() and Url.RouteUrl() work in controllers and services; Tag Helpers (asp-controller, asp-action) work in views. When a route template changes, generated URLs update automatically everywhere. This is one of the most underappreciated features of ASP.NET Core routing — used consistently, it makes URL refactoring cost-free.
URL Generation Methods
// ── In a controller ────────────────────────────────────────────────────────
public class PostsController : Controller
{
// Generate URL for same controller, different action
string indexUrl = Url.Action(nameof(Index)); // /posts
string detailsUrl = Url.Action(nameof(Details), new { id = 42 }); // /posts/details/42
// Generate URL for different controller
string homeUrl = Url.Action(nameof(HomeController.Index), "Home"); // /
// Generate URL using named route
string canonical = Url.RouteUrl("GetPostById", new { id = 42 }); // /posts/42
// Generate absolute URL (with scheme and host)
string absolute = Url.Action(nameof(Details),
new { id = 42 },
protocol: Request.Scheme, // "https"
host: Request.Host.Value); // "www.blogapp.com"
// → https://www.blogapp.com/posts/details/42
// Generate redirect using named route
public IActionResult CreateSuccess(int newId)
=> RedirectToRoute("GetPostById", new { id = newId });
}
// ── In views — Tag Helper URL generation ──────────────────────────────────
@* Anchor — generates /posts/details/42 *@
<a asp-controller="Posts" asp-action="Details" asp-route-id="@post.Id">
@post.Title
</a>
@* Using named route *@
<a asp-route="GetPostById" asp-route-id="@post.Id">@post.Title</a>
@* Form action *@
<form asp-controller="Posts" asp-action="Create" method="post">
@* generates action="/posts/create" *@
</form>
@* Pagination links *@
<a asp-action="Index" asp-route-page="@(Model.Page + 1)">Next →</a>
{controller=Home}/{action=Index}/{id?} and the current controller is Posts, calling Url.Action("Details") (no controller specified) uses the ambient controller=Posts value and generates /posts/details. This is convenient — links within the same controller do not need to specify the controller. However, it can be surprising: the same Url.Action("Details") call generates different URLs depending on which controller’s action is currently executing. Always specify the controller explicitly when generating links to other controllers.LinkGenerator (injectable service) to generate URLs outside of HTTP context — in background services, email templates, and API response bodies. IUrlHelper (from controllers and views) requires an active HTTP context; LinkGenerator works anywhere. Inject it via DI: public class EmailService(LinkGenerator linkGenerator), then: linkGenerator.GetPathByAction(httpContext, "Details", "Posts", new { id = postId }). This is how you generate correct URLs for inclusion in emails without building URL strings manually.null (not an exception) when no matching route is found. This happens when the route does not exist, required route values are missing, or route constraints are not satisfied. A null URL in a view renders as an href="" link — broken but not immediately obvious. Add null checks: var url = Url.RouteUrl("RouteNotExist"); if (url is null) throw new InvalidOperationException("..."); in critical paths. For Tag Helpers, they silently render empty href on no-match — test all generated links in integration tests.LinkGenerator — URL Generation Outside HTTP Context
// ── LinkGenerator for services and background workers ────────────────────
public class EmailNotificationService(
LinkGenerator linkGenerator,
IHttpContextAccessor httpContextAccessor,
IEmailSender emailSender)
{
public async Task SendPostPublishedEmailAsync(Post post, string authorEmail)
{
// Generate absolute URL for inclusion in email body
var ctx = httpContextAccessor.HttpContext;
var postUrl = linkGenerator.GetUriByAction(
ctx!,
action: "Details",
controller: "Posts",
values: new { id = post.Id },
scheme: "https",
host: new HostString("www.blogapp.com"));
await emailSender.SendAsync(
authorEmail,
"Your post is live!",
$"Your post '{post.Title}' has been published. View it at: {postUrl}");
}
}
Common Mistakes
Mistake 1 — Hardcoding URLs in views and controllers
❌ Wrong — breaks silently when routes change:
<a href="/posts/details/@post.Id">View</a> @* hardcoded — breaks on route rename *@
✅ Correct — use Tag Helpers: <a asp-controller="Posts" asp-action="Details" asp-route-id="@post.Id">
Mistake 2 — Not checking Url.Action() for null (silent broken links)
❌ Wrong — null URL renders as empty href with no error.
✅ Correct — check for null in critical link generation, especially for named routes.