← All posts

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.

Artur Kot 7 min read

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.

Topics: aspnetdotnetnarzedzia

The download has started

DevJourney_1.0.0_x64-setup.exe · 1.0.0

If Windows warns you

"Windows protected your PC" is not a virus detection. SmartScreen trusts the certificate an installer is signed with, and this one is still earning its reputation through a download count.

In the warning: More info → Run anyway.

Confirm it yourself

Check this download on VirusTotal

Or in PowerShell, where you downloaded it:

Get-FileHash .\DevJourney_1.0.0_x64-setup.exe

Should print:

7cd001be4463317f601b8bb2eed78537982a3559c92f06e96288434c79931fd6