Your First ASP.NET Core Minimal API
Four route mappings in one Program.cs produce a working HTTP server. Real responses, real headers, and the two mistakes you'll hit on your first POST.
Below is a complete, working API in a single file: four routes, no controller, no separate service layer. That’s what it takes to get your first ASP.NET Core minimal API answering real HTTP requests instead of just printing one line to a console.
This post walks through what an ASP.NET Core minimal API looks like step
by step: starting from an empty template, through four routes working on a
task list, to checking a response with curl and the two mistakes you’ll
hit on your first POST.
Step 0: confirm the empty template already works
Before adding anything of your own, it’s worth seeing the bare template
run. dotnet new web scaffolds a project with one endpoint:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
Six lines. That’s a complete ASP.NET Core application. WebApplication
sets up the Kestrel server, MapGet registers a route, Run starts
listening. If this runs the same way on your machine, everything past this
point is just adding more routes to the same file.
Notice what’s missing. No separate Startup.cs file, no XML config
anywhere. The whole application lives in one place you read top to bottom.
While adding routes, dotnet watch run is more convenient than a plain
dotnet run. The server restarts itself after every saved file. There’s
no manual stop-and-retype cycle between edits.
Four routes instead of one endpoint
Instead of returning a plain string, the Program.cs below keeps a task
list in memory and exposes it through four routes. The setup and the data
type first, unabridged:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var tasks = new List<TaskItem>
{
new(1, "Set up the project", true),
new(2, "Write the first endpoint", false),
};
TaskItem is a record, a type built for exactly this: a few read-only
fields, no constructor and no Equals override to write. The whole
definition fits on one line, at the very end of the file:
record TaskItem(int Id, string Title, bool Done);
And here are the routes themselves, added below tasks, together with
app.Run(), which starts the server listening:
app.MapGet("/", () => "API running.");
app.MapGet("/tasks", () => tasks);
app.MapGet("/tasks/{id:int}", (int id) =>
{
var task = tasks.FirstOrDefault(t => t.Id == id);
return task is null ? Results.NotFound() : Results.Ok(task);
});
app.MapPost("/tasks", (TaskItem task) =>
{
tasks.Add(task);
return Results.Created($"/tasks/{task.Id}", task);
});
app.Run();
Every Map line returns something different. That’s the actual shape of
a minimal API: there’s no default behavior to override, only what you
wrote. MapGet("/tasks", ...) returns a list, so ASP.NET Core serializes
it to JSON on its own. MapGet("/tasks/{id:int}", ...) returns either
Results.NotFound() or Results.Ok(), picking the status code explicitly
instead of guessing it from a return type. {id:int} in the route rejects
anything that isn’t a number before your code even runs.
Start it the same way as the template:
dotnet run
Every command above ran on SDK version 10.0.302, the version that ships with .NET 10. The console prints the real address and port it’s listening on:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5299
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /tmp/minimal-api-demo
Checking the response without writing a frontend
Building a UI that calls the API is a separate project. To confirm a
route does what it should, curl with the -D - flag prints the response
headers ahead of the body:
curl -D - http://localhost:5299/tasks
The actual response to that call looks like this:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 09 Sep 2026 10:17:32 GMT
Server: Kestrel
Transfer-Encoding: chunked
[{"id":1,"title":"Set up the project","done":true},{"id":2,"title":"Write the first endpoint","done":false}]
Content-Type: application/json confirms ASP.NET Core recognized the
list of records and picked the format itself. Look at the field names
too: id, title and done start with a lowercase letter, even though
the TaskItem record spells them Id, Title and Done. This is the
JSON serializer’s default in ASP.NET Core. Nothing to turn on.
Asking for a task that exists returns 200 OK with a single object
instead of an array. Asking for an id that isn’t on the list hits the
Results.NotFound() branch and comes back like this:
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Wed, 09 Sep 2026 10:17:32 GMT
Server: Kestrel
Zero in Content-Length says the same thing as the status code: the body
is empty, because Results.NotFound() returns nothing but the status
itself.
Adding a task through POST with a JSON body returns 201 Created and a
Location header pointing at the new resource, exactly as
Results.Created sets it in the code:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /tasks/3
Server: Kestrel
{"id":3,"title":"Add tests","done":false}
Four requests, four different status codes. None of it needed a browser open.
The two mistakes you’ll hit on the first POST
The first one shows up when you send the same JSON body without a
Content-Type header:
curl -X POST http://localhost:5299/tasks -d '{"id":4,"title":"No header","done":false}'
HTTP/1.1 415 Unsupported Media Type
Content-Length: 0
Server: Kestrel
The minimal API binds the TaskItem task parameter from the request
body, but only when the header explicitly says the body is JSON. Without
it, the framework rejects the request before it even tries to parse the
content, so curl needs -H "Content-Type: application/json" added
explicitly.
The second comes from invalid JSON, for example a missing closing brace:
curl -X POST http://localhost:5299/tasks -H "Content-Type: application/json" -d '{"id":5,"title":'
HTTP/1.1 400 Bad Request
Content-Length: 0
Server: Kestrel
This time the header is correct, but the JSON parser rejects the body
before any line inside MapPost runs. The difference between the two
codes isn’t arbitrary: 415 means the server refused to read the body at
all, and 400 means it tried and failed. Both are worth recognizing.
They happen at the framework level, not in your code, and that’s not
something a written description shows without an actual request behind
it.
When a minimal API stops being enough
Four routes in one file read easily. Fifteen don’t, especially once
three of them need the same input validation or the same database read.
The instinct is to pull the logic into a separate method, but the method
still lives in the same Program.cs. The problem moves rather than
disappears.
One intermediate step, before full controllers, is MapGroup: it groups
several routes under a shared prefix and shared settings, without moving
logic into separate classes. Calling app.MapGroup("/tasks") means every
route registered on that group stops repeating the /tasks segment in
its own address. That fixes repetition in route configuration. It doesn’t
fix a growing file, if the business logic underneath keeps growing too.
Grouping routes alone isn’t enough.
The natural point for controllers and separate layers arrives once routes start sharing more than an in-memory list: a real database through EF Core, authentication, or validation that has to behave identically in five different places. Until then, one file is faster to write and easier to read than a folder structure designed for a project that doesn’t exist yet. The in-memory task list from this post disappears the moment the server restarts anyway, so it was only ever a starting point for a longer conversation about real storage, not the end of it.
DevJourney gets to ASP.NET Core only after the curriculum
covers classes, LINQ, and working with a real database, because that’s
the point where a minimal API has something to compete against. Before
that, it helps to know how to read a compiler error
message and to have the SDK
actually installed, since
both come up before the first dotnet run.