Every request that reaches an ASP.NET Core MVC application ends in a controller action, and every action returns an action result — the object that tells the framework what response to write. This tutorial covers what makes a class a controller, what makes a method an action, and the action result types you will actually use, each verified with the real HTTP response it produces.

Controllers
A controller is a class that handles requests for one resource or area. MVC discovers it by convention — the Controller suffix — and routing maps URLs onto it:
public class DemoController : Controller
{
// actions go here
}
Two base classes matter:
Controller— for pages: addsView(),ViewData,TempData, and everything Razor needs.ControllerBase— for APIs: everything except views. Pair it with[ApiController]for automatic model validation responses and body binding.
Action methods
Every public method on a controller is an action — reachable over HTTP — unless you opt it out with [NonAction]. An action's job: take input (via model binding), do the work, and return a result.
Return types you can declare:
- A specific result (
JsonResult,ContentResult) — precise, but locks the action to one response kind. IActionResult— the common choice: one action can returnOk(...)in the happy path andNotFound()in another branch.ActionResult<T>— best for APIs: documents the success typeTfor OpenAPI while still allowing status-code results.
The action results, verified
Each of these was executed; the response shown is what actually came back.
ViewResult — render a page
public IActionResult Index() => View(products);
Renders the Razor view for the action with the model — the everyday result for MVC pages.
JsonResult — return data
public JsonResult AsJson() => Json(new { id = 1, name = "Galaxy A15" });
GET /demo/asjson → 200, {"id":1,"name":"Galaxy A15"}
Serialized with System.Text.Json, camelCased by default.
ContentResult — raw text
public ContentResult AsText() => Content("plain text response", "text/plain");
GET /demo/astext → 200, Content-Type: text/plain
Status-code results
public IActionResult Missing() => NotFound(); // 404
public IActionResult Invalid() => BadRequest(new { error = "price must be positive" }); // 400 + body
public IActionResult NoBody() => NoContent(); // 204
public StatusCodeResult Teapot() => StatusCode(418); // anything else
Verified: 404, 400 with the JSON error body, 204, 418. Ok(value) (200), CreatedAtAction(...) (201 + Location header) round out the API set — see them in action in CRUD operations using ASP.NET Core.
RedirectResult family
public IActionResult Moved() => RedirectToAction("AsJson");
GET /demo/moved → 302 Location: /Demo/AsJson
RedirectToAction, RedirectToRoute, Redirect(url), and their Permanent variants (301) — use permanent redirects when a URL has moved for good, so search engines transfer ranking.
FileResult — downloads
public FileResult DownloadBytes()
{
var bytes = Encoding.UTF8.GetBytes("ProductId,Name\n1,Galaxy A15\n");
return File(bytes, "text/csv", "products.csv");
}
GET /demo/downloadbytes → Content-Type: text/csv
Content-Disposition: attachment; filename=products.csv
Byte arrays, streams, and physical files each have an overload — the full treatment is in returning a file using FileResult.
ActionResult<T> and async actions
For APIs, ActionResult<T> is the modern signature — it documents the success type for OpenAPI while every status-code helper still works:
[HttpGet("{id:int}")]
public async Task<ActionResult<Supplier>> Get(int id, CancellationToken ct)
{
var supplier = await db.Suppliers.FindAsync([id], ct);
return supplier is null ? NotFound() : supplier; // implicit conversion to 200
}
Three things worth internalizing:
- Returning the object converts to
200 OKimplicitly; returningNotFound()short-circuits — one signature, both outcomes typed. - Any action that does I/O should be
async Task<…>— database calls, HTTP calls, file reads. The thread goes back to the pool while the work runs; under load this is the difference between serving requests and queueing them. - Accepting a
CancellationTokenparameter (bound automatically from the request) lets aborted requests stop the query instead of running to completion for a client that already left.
PartialViewResult and view components
Pages composed from fragments use two more results:
public IActionResult Row(int id) => PartialView("_SupplierRow", Find(id));
PartialView renders a view without the layout — the classic target for AJAX that replaces one section of a page. View components (ViewComponent classes returning IViewComponentResult) are their richer sibling: a mini controller+view pair invokable from any layout or page, right for self-contained widgets like a notification bell or cart summary.
Writing a custom action result
Every result is just a class implementing IActionResult — one method. A CSV result makes the pattern concrete:
public class CsvResult(IEnumerable<string[]> rows, string fileName) : IActionResult
{
public async Task ExecuteResultAsync(ActionContext context)
{
var response = context.HttpContext.Response;
response.ContentType = "text/csv";
response.Headers.ContentDisposition = $"attachment; filename={fileName}";
foreach (var row in rows)
await response.WriteAsync(string.Join(',', row) + "\n");
}
}
public IActionResult Export() => new CsvResult(GetRows(), "suppliers.csv");
The framework calls ExecuteResultAsync when the action returns — your class owns the response from there. Most needs are covered by the built-ins, but the extension point keeps controllers declarative when you do need something bespoke (server-sent events, custom streaming, exotic formats).
Picking the right result
- Page →
View(). Data for JavaScript →Json()or, in APIs, just return the object. - Client mistake →
BadRequest/NotFound/UnprocessableEntity, never a 200 with an error string — status codes are the API contract. - After a successful POST in MVC pages → redirect (
RedirectToAction) so refresh doesn't resubmit the form (the POST-redirect-GET pattern). - Everything a browser downloads → a
FileResultwith a real content type and filename.
The complete runnable controller for this article is in the companion repo — every endpoint above works with dotnet run and curl.
Comments (0)
No comments yet — be the first to share your thoughts.