REST Best Practices for ASP.NET Core APIs: Status Codes, Pagination, Filtering, and Versioning
Published:
This post covers the API design habits that matter once you move beyond “it works on my machine” and start building endpoints other systems will rely on. Good REST design is mostly about clear resource modeling, predictable status codes, safe query patterns, and a versioning strategy you decide before clients are locked in.
What REST best practices are really about
REST is often explained as a set of abstract constraints, but in day-to-day API design the practical questions are simpler:
- are your URLs resource-oriented
- do status codes match the outcome
- can clients page and filter large datasets safely
- can you evolve the API without breaking consumers
Most API pain comes from inconsistency, not from lack of sophistication.
Use resource-oriented URLs
A common beginner mistake is designing endpoints around verbs:
/getProducts
/createOrder
/deleteCustomer
Prefer nouns that represent resources:
GET /api/products
GET /api/products/42
POST /api/orders
DELETE /api/customers/10
Why this is better:
- the HTTP verb already expresses the action
- the URL describes the resource, not the operation name
- the API becomes easier to reason about consistently
Nested resources can be useful when the relationship is real:
GET /api/orders/42/items
But do not over-nest everything. Deep URLs often signal that the data model is leaking into the API surface too aggressively.
Status codes should match the outcome
Status codes are part of the contract. Clients use them to decide what to do next.
Common choices:
200 OKfor successful reads201 Createdwhen a new resource is created204 No Contentfor successful updates or deletes without a response body400 Bad Requestfor invalid request shape401 Unauthorizedwhen authentication is missing or invalid403 Forbiddenwhen the caller is authenticated but not allowed404 Not Foundwhen the resource does not exist409 Conflictwhen the request conflicts with current state
Example create endpoint:
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
var product = new { Id = 42, request.Name, request.Price };
return Created($"/api/products/{product.Id}", product);
}
Why 201 Created matters:
- it signals that a new resource now exists
- it usually includes the resource location
- clients can distinguish creation from a generic success
Do not return 200 OK for every success just because it is easy.
Pagination for collection endpoints
Large collections should be paged. Returning thousands of rows in one response hurts performance and creates unstable client behavior.
Offset pagination is the easiest starting point:
GET /api/products?page=2&pageSize=25
Example response model:
public sealed record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount);
Controller example:
[HttpGet]
public IActionResult GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 25)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var items = productService.GetPage(page, pageSize);
var totalCount = productService.GetCount();
return Ok(new PagedResult<ProductDto>(items, page, pageSize, totalCount));
}
Rules worth enforcing:
- clamp page size to a safe maximum
- document default values
- return enough metadata for clients to navigate
For very large or fast-changing datasets, cursor-based pagination can be a better fit, but offset pagination is the easiest baseline to teach and support.
Filtering and sorting
Filtering should usually live in query parameters:
GET /api/products?category=keyboards&minPrice=50&maxPrice=200&sort=name
Minimal API example:
app.MapGet("/api/products", (
string? category,
decimal? minPrice,
decimal? maxPrice,
string? sort) =>
{
var query = productService.Query();
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(p => p.Category == category);
if (minPrice.HasValue)
query = query.Where(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue)
query = query.Where(p => p.Price <= maxPrice.Value);
query = sort?.ToLowerInvariant() switch
{
"price" => query.OrderBy(p => p.Price),
"name" => query.OrderBy(p => p.Name),
_ => query.OrderBy(p => p.Id)
};
return Results.Ok(query.ToList());
});
Good filtering design:
- keeps query keys predictable
- avoids inventing a different format for every endpoint
- validates inputs instead of accepting arbitrary sort fields blindly
Versioning strategy
Versioning matters when your API will have external consumers or long-lived clients. Breaking changes eventually happen. Versioning gives you room to evolve deliberately.
Common strategies:
- URL segment versioning, such as
/api/v1/products - query string versioning, such as
/api/products?api-version=1.0 - header-based versioning
URL versioning is the easiest to understand:
GET /api/v1/products
GET /api/v2/products
Why teams often choose it:
- simple to document
- obvious in logs and routes
- easy for clients to test manually
Whichever strategy you choose, the key is consistency. Do not version one part of the API in the URL and another part through headers unless you have a very specific reason.
A practical checklist
For most real-world APIs, this checklist is a solid baseline:
- noun-based URLs
- correct status codes
- pagination for lists
- filtering and sorting through query parameters
- versioning planned before the API becomes widely consumed
- consistent error responses
If these are in place, your API will feel much more professional even before you add advanced features.
Common mistakes to avoid
Watch for these issues:
- action-style URLs like
/createUser - returning unbounded collections
- using
POSTfor every operation - returning
200when a201,204, or404would be more accurate - introducing breaking response changes with no versioning plan
A well-designed REST API is predictable. Clients should not have to guess what your endpoint means or how it behaves.
Next Article: Authentication Basics for .NET APIs: Cookies vs JWT vs OAuth2/OIDC
