HATEOAS in Practice: Hypermedia Links in .NET APIs
HATEOAS — Hypermedia as the Engine of Application State — is the most debated REST constraint. In theory, it means clients discover available actions through links in responses rather than hardcoding URLs. In practice, most APIs ignore it completely, and the few that implement it often over-engineer it into irrelevance. This article takes a pragmatic middle ground: add links where they genuinely help clients, without building a full hypermedia framework.
What HATEOAS Looks Like
A HATEOAS-enabled response includes links that tell the client what it can do next:
{
"id": 42,
"customerName": "Acme Ltd",
"status": "confirmed",
"total": 199.99,
"_links": {
"self": { "href": "/api/orders/42" },
"cancel": { "href": "/api/orders/42/cancel", "method": "POST" },
"items": { "href": "/api/orders/42/items" }
}
}
If the order were already shipped, the cancel link would be absent — the client does not need to know the business rules for when cancellation is allowed. It simply checks whether the link exists.
Building a Link Model
Start with a simple link representation:
public class Link
{
public required string Href { get; set; }
public string Method { get; set; } = "GET";
public string? Title { get; set; }
}
public class LinkedResource<T>
{
public required T Data { get; set; }
[JsonPropertyName("_links")]
public Dictionary<string, Link> Links { get; set; } = [];
}
This gives you a generic wrapper that works with any resource type.
Generating Links
Use LinkGenerator — built into ASP.NET Core — to generate URLs from named endpoints:
app.MapGet("/api/orders/{id:int}", async (
int id,
AppDbContext db,
LinkGenerator linkGenerator,
HttpContext httpContext,
CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
var links = new Dictionary<string, Link>
{
["self"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "GetOrder", new { id })!
},
["items"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "GetOrderItems", new { orderId = id })!
}
};
// Conditional links based on state
if (order.Status == OrderStatus.Confirmed)
{
links["cancel"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "CancelOrder", new { id })!,
Method = "POST",
Title = "Cancel this order"
};
}
if (order.Status == OrderStatus.Confirmed || order.Status == OrderStatus.Pending)
{
links["update"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "UpdateOrder", new { id })!,
Method = "PUT"
};
}
return Results.Ok(new LinkedResource<Order> { Data = order, Links = links });
})
.WithName("GetOrder");
The conditional links are the real value here. The cancel link only appears when cancellation is a valid action. Clients become simpler because they do not need to replicate business rules.
Links on Collections
For collections, add links at both the collection and item level:
app.MapGet("/api/orders", async (
int page,
int pageSize,
AppDbContext db,
LinkGenerator linkGenerator,
HttpContext httpContext,
CancellationToken ct) =>
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var totalCount = await db.Orders.CountAsync(ct);
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
var items = await db.Orders
.OrderBy(o => o.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
var links = new Dictionary<string, Link>
{
["self"] = new Link
{
Href = $"/api/orders?page={page}&pageSize={pageSize}"
}
};
if (page < totalPages)
{
links["next"] = new Link
{
Href = $"/api/orders?page={page + 1}&pageSize={pageSize}"
};
}
if (page > 1)
{
links["prev"] = new Link
{
Href = $"/api/orders?page={page - 1}&pageSize={pageSize}"
};
}
return Results.Ok(new
{
items,
totalCount,
_links = links
});
})
.WithName("GetOrders");
Pagination links are arguably the most universally useful form of HATEOAS. They eliminate the need for clients to construct pagination URLs.
Extracting Link Generation
As your API grows, centralise link generation to avoid duplication:
public class OrderLinkBuilder(LinkGenerator linkGenerator, HttpContext httpContext)
{
public Dictionary<string, Link> BuildLinks(Order order)
{
var links = new Dictionary<string, Link>
{
["self"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "GetOrder", new { id = order.Id })!
}
};
if (order.CanBeCancelled)
{
links["cancel"] = new Link
{
Href = linkGenerator.GetPathByName(httpContext, "CancelOrder", new { id = order.Id })!,
Method = "POST"
};
}
return links;
}
}
Register it as a scoped service so it has access to the current HttpContext.
The Pragmatic Approach
Full HATEOAS — where clients start from a root document and follow links exclusively — is rarely practical. Most API clients are purpose-built applications, not generic hypermedia browsers. The pragmatic approach is to add links where they provide concrete value:
- Pagination links — always useful, eliminate URL construction.
- State-dependent action links — reduce client-side business rule duplication.
- Related resource links — help clients discover connections without documentation.
- Self links — useful for caching and bookmarking.
Skip links that add noise without helping clients make decisions. HATEOAS is a tool, not a religion.