HATEOAS in ASP.NET Core Web API
When building REST APIs, clients often hardcode endpoint URLs for every action they need.
That works at first. Then a route changes, a resource moves or a new action appears and every consumer needs an update.
The API becomes tightly coupled to how clients construct URLs, not just to the data they exchange.
To address this, we can return hypermedia links with each resource. That's where HATEOAS comes in.
What is HATEOAS?
HATEOAS stands for Hypermedia As The Engine Of Application State.
It is one of the constraints of REST. Instead of only returning data, the API also returns links that describe what the client can do next.
Those links usually include a relation name, a URL and optionally an HTTP method.
The client follows the links it receives. It does not need to know how every URL is built up front.
A typical HATEOAS response for a product looks like this:
{
"data": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Wireless Mouse",
"description": "Ergonomic wireless mouse",
"price": 29.99
},
"links": [
{
"rel": "self",
"href": "/api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": null
},
{
"rel": "update",
"href": "/api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "PUT"
},
{
"rel": "delete",
"href": "/api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"method": "DELETE"
}
]
}
The payload stays familiar. The links array tells the client how to continue.
Modeling Links
ASP.NET Core does not ship a built-in HATEOAS package. You model it yourself with a few small types.
Start with a Link record:
public sealed record Link(
string Rel,
string Href,
string? Method = null);
The fields are simple:
- Rel - The relation name, such as self, update or delete
- Href - The URL the client should call
- Method - The HTTP method when it is not GET
Next, wrap your resource with a generic HateoasResponse:
public sealed record HateoasResponse<T>(
T Data,
IReadOnlyCollection<Link> Links);
And keep your product DTO clean:
public sealed record ProductResponse(
Guid Id,
string Name,
string Description,
decimal Price);
This separation keeps domain data and navigation concerns apart. Clients still get one response object that includes both.
HATEOAS with Controllers
In controller-based APIs, Url.Action is the easiest way to generate route URLs.
Here's how a GetById endpoint can return a product with self, update and delete links:
[HttpGet("{productId:guid}")]
public async Task<IActionResult> GetById(
Guid productId,
CancellationToken cancellationToken)
{
var product = await _sender.Send(
new GetProductByIdQuery(productId),
cancellationToken);
if (product is null)
{
return NotFound();
}
var hateoas = new HateoasResponse<ProductResponse>(
product,
new[]
{
new Link(
"self",
Url.Action(nameof(GetById), new { productId })!),
new Link(
"update",
Url.Action(nameof(Update), new { productId })!,
"PUT"),
new Link(
"delete",
Url.Action(nameof(Delete), new { productId })!,
"DELETE"),
});
return Ok(hateoas);
}
Url.Action builds the href from the route template. If the route changes later, the links update automatically.
The same idea works for collections. Map each product into its own HateoasResponse:
[HttpGet]
public async Task<IActionResult> GetAll(
CancellationToken cancellationToken)
{
var products = await _sender.Send(
new GetAllProductsQuery(),
cancellationToken);
var items = products
.Select(p => new HateoasResponse<ProductResponse>(
p,
new[]
{
new Link(
"self",
Url.Action(
nameof(GetById),
new { productId = p.Id })!),
new Link(
"update",
Url.Action(
nameof(Update),
new { productId = p.Id })!,
"PUT"),
new Link(
"delete",
Url.Action(
nameof(Delete),
new { productId = p.Id })!,
"DELETE"),
}))
.ToList();
return Ok(items);
}
Each item in the list carries its own navigation. The client can act on a single product without reconstructing its URL.
HATEOAS with Minimal APIs
Minimal APIs do not expose Url.Action the same way controllers do. A practical approach is to build absolute URLs from HttpContext.
app.MapGet("products/{id:guid}", async (
HttpContext http,
ISender sender,
Guid id,
CancellationToken cancellationToken) =>
{
var product = await sender.Send(
new GetProductByIdQuery(id),
cancellationToken);
if (product is null)
{
return Results.NotFound();
}
var baseUrl =
$"{http.Request.Scheme}://{http.Request.Host}/products/{id}";
var links = new List<Link>
{
new("self", baseUrl),
new("update", baseUrl, "PUT"),
new("delete", baseUrl, "DELETE"),
};
return Results.Ok(
new HateoasResponse<ProductResponse>(product, links));
});
You can also inject LinkGenerator if you prefer named endpoints and typed route values. The idea stays the same, return links with the resource.
For a list endpoint, project each product the same way:
app.MapGet("products/", async (
HttpContext http,
ISender sender,
CancellationToken cancellationToken) =>
{
var products = await sender.Send(
new GetAllProductsQuery(),
cancellationToken);
var items = products
.Select(p =>
{
var href =
$"{http.Request.Scheme}://{http.Request.Host}/products/{p.Id}";
return new HateoasResponse<ProductResponse>(
p,
new List<Link>
{
new("self", href),
new("update", href, "PUT"),
new("delete", href, "DELETE"),
});
})
.ToList();
return Results.Ok(items);
});
Whether you use controllers or Minimal APIs, the response contract stays consistent.
Practical Tips
A few details make HATEOAS more useful in real APIs.
- Keep relation names stable - Clients depend on rel values more than on exact URL shapes
- Include the HTTP method - Especially for PUT, PATCH and DELETE actions
- Return only allowed links - If the user cannot delete a product, omit the delete link
- Prefer generated URLs - Use Url.Action or LinkGenerator so route changes stay centralized
HATEOAS also pairs well with other API practices. When something goes wrong, return a consistent error shape with Problem Details.
And when the API itself evolves, API versioning still matters. Links help with navigation. Versioning helps with breaking changes.
Conclusion
HATEOAS turns your API responses into more than data dumps. They become navigable resources.
With a small Link model and a HateoasResponse wrapper, you can add hypermedia to both controllers and Minimal APIs without a heavy framework.
Clients follow the links you return. That reduces hardcoded URLs and makes the API easier to evolve over time.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!



