URL Generation — Url.Action, Url.RouteUrl and Tag Helpers

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>
Note: ASP.NET Core routing uses ambient route values when generating URLs. If the current request matches route {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.
Tip: Use 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.
Warning: URL generation returns 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">

❌ Wrong — null URL renders as empty href with no error.

✅ Correct — check for null in critical link generation, especially for named routes.

🧠 Test Yourself

You rename PostsController.Details to PostsController.Show. All links use asp-action="Details" Tag Helpers. What happens to those links?