Your First Minimal API in .NET 8: From Empty Program to JSON in 10 Minutes

From Zero to Working API Fast

You need a simple HTTP API that returns JSON. No database, no authentication, just a working endpoint you can call from JavaScript or curl. Traditional API frameworks make you create controllers, configure routing, and understand MVC patterns before you write a single line of business logic.

Minimal APIs in .NET 8 remove that friction. You can define endpoints directly in Program.cs using lambda expressions. No classes, no attributes, no ceremony. Just routes and handlers. This approach gets you to a working API in minutes instead of hours.

You'll build a complete API from scratch that handles GET and POST requests, returns JSON, and demonstrates parameter binding. By the end, you'll have a pattern you can reuse for any simple API project.

The Simplest Possible API

Start with the absolute minimum code needed for an HTTP endpoint. The web template in .NET 8 gives you a skeleton project. You'll replace its contents with a single MapGet call that returns a string. This proves your environment works and shows the basic structure.

Every Minimal API follows the same pattern: create a WebApplicationBuilder, build the app, map endpoints, and call Run. The builder configures services and middleware. The app instance registers your routes. This two-step setup applies whether you have one endpoint or fifty.

Here's the simplest working API you can create.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello from Minimal API!");

app.Run();

When you run this and visit http://localhost:5000, you'll see the text response. The MapGet method registers a route pattern and a handler. The handler is a lambda that returns a string. ASP.NET Core automatically sets the content type to text/plain and returns a 200 OK status.

Returning JSON Data

Most APIs return structured data as JSON instead of plain text. When your handler returns an object or collection, the framework serializes it to JSON automatically. You don't need to manually call a serializer or set content type headers. The built-in JSON serializer handles primitive types, objects, arrays, and nested structures.

You can return anonymous types for quick responses or define record types for stronger typing. Records are perfect for API responses because they're immutable, have built-in equality, and support with-expressions for creating modified copies.

Here's an endpoint that returns a JSON object representing a product.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/product", () => new Product(
    Id: 1,
    Name: "Laptop",
    Price: 999.99m,
    InStock: true
));

app.MapGet("/products", () => new[]
{
    new Product(1, "Laptop", 999.99m, true),
    new Product(2, "Mouse", 24.99m, true),
    new Product(3, "Keyboard", 79.99m, false)
});

app.Run();

record Product(int Id, string Name, decimal Price, bool InStock);

The /product endpoint returns a single object serialized to JSON. The /products endpoint returns an array. The framework handles the serialization, sets Content-Type to application/json, and formats the output with camelCase property names by default. Clients receive clean, predictable JSON they can parse immediately.

Capturing Route Parameters

Real APIs need to accept input from clients. Route parameters let you capture values from the URL path itself, like /products/42 where 42 is the product ID. You define parameters in the route pattern using curly braces, then declare matching parameters in your handler lambda.

The framework binds route values to your parameters automatically. It handles type conversion, so if you declare an int parameter, the framework parses the URL segment as an integer. If parsing fails, the request returns a 400 Bad Request before your handler executes.

Here's how to build a route that retrieves a specific product by ID.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var products = new List<Product>
{
    new(1, "Laptop", 999.99m, true),
    new(2, "Mouse", 24.99m, true),
    new(3, "Keyboard", 79.99m, false)
};

app.MapGet("/products/{id:int}", (int id) =>
{
    var product = products.FirstOrDefault(p => p.Id == id);

    return product is not null
        ? Results.Ok(product)
        : Results.NotFound(new { Error = "Product not found" });
});

app.Run();

record Product(int Id, string Name, decimal Price, bool InStock);

The route constraint :int ensures the ID is numeric. Inside the handler, you search for the product and return either Results.Ok with the product or Results.NotFound with an error message. The Results helper methods create properly formatted HTTP responses with correct status codes and JSON bodies.

Accepting POST Data

POST endpoints receive data in the request body instead of the URL. The framework can bind JSON payloads directly to your parameters. If your handler declares a parameter with a complex type, the framework deserializes the request body into that type automatically.

This binding works for records, classes, and even anonymous types. You can combine body parameters with route parameters and query strings in the same handler. The framework knows where to get each value based on the parameter's position and type.

Here's a POST endpoint that creates a new product from JSON input.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var products = new List<Product>
{
    new(1, "Laptop", 999.99m, true),
    new(2, "Mouse", 24.99m, true)
};

app.MapPost("/products", (CreateProductRequest request) =>
{
    var newProduct = new Product(
        Id: products.Max(p => p.Id) + 1,
        Name: request.Name,
        Price: request.Price,
        InStock: request.InStock
    );

    products.Add(newProduct);

    return Results.Created($"/products/{newProduct.Id}", newProduct);
});

app.MapGet("/products", () => products);

app.Run();

record Product(int Id, string Name, decimal Price, bool InStock);
record CreateProductRequest(string Name, decimal Price, bool InStock);

The CreateProductRequest record defines the expected input shape. When a client POSTs JSON like {"name":"Monitor","price":299.99,"inStock":true}, the framework deserializes it into the request parameter. You create a new product with a generated ID and return 201 Created with the new resource's location and body.

Using Query String Parameters

Query parameters pass optional data through the URL like /products?category=electronics&maxPrice=500. They're perfect for filtering, sorting, and pagination. You capture query parameters the same way as route parameters, just declare them in your handler signature.

The framework binds query string values by name. If the parameter is nullable or has a default value, it's optional. If it's required and missing, the request fails with a 400 error. This gives you compile-time safety for your API contracts.

Here's an endpoint that filters products using query parameters.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var products = new List<Product>
{
    new(1, "Laptop", "electronics", 999.99m, true),
    new(2, "Mouse", "electronics", 24.99m, true),
    new(3, "Desk", "furniture", 299.99m, true),
    new(4, "Chair", "furniture", 199.99m, false)
};

app.MapGet("/products", (string? category, decimal? maxPrice) =>
{
    var filtered = products.AsEnumerable();

    if (category is not null)
        filtered = filtered.Where(p =>
            p.Category.Equals(category, StringComparison.OrdinalIgnoreCase));

    if (maxPrice.HasValue)
        filtered = filtered.Where(p => p.Price <= maxPrice.Value);

    return Results.Ok(filtered);
});

app.Run();

record Product(int Id, string Name, string Category, decimal Price, bool InStock);

Both parameters are optional (nullable). If neither is provided, all products return. If only category is specified, you filter by category. If both are present, you apply both filters. This pattern scales well for complex filtering scenarios without requiring separate endpoints for each combination.

Common Mistakes

Forgetting app.Run(): If your API starts and immediately exits, you probably forgot the app.Run() call at the end. This blocking call keeps the web server running and listening for requests. Without it, your program completes and shuts down instantly.

Wrong parameter names: Route and query parameters bind by name. If your route is /products/{id} but your handler takes an int productId, binding fails. The parameter name must match exactly, though casing is flexible (id matches Id).

Missing type constraints: Without route constraints like {id:int}, the framework treats all route values as strings. If you expect an integer, add the constraint. This provides early validation and clearer error messages when clients pass invalid values.

Not handling null or missing data: Always check if a requested resource exists before returning it. Use Results.NotFound() for missing items and Results.BadRequest() for invalid input. Don't return null directly as it triggers serialization errors.

Build It Yourself

Create a complete API with GET, POST, and filtering in under 10 minutes. You'll see how little code you need for a functional HTTP API.

Steps

  1. dotnet new web -n MyFirstApi
  2. cd MyFirstApi
  3. Open Program.cs and replace with the code below
  4. dotnet run
  5. Test GET: curl http://localhost:5000/tasks
  6. Test POST: curl -X POST http://localhost:5000/tasks -H "Content-Type: application/json" -d '{"title":"Buy milk","done":false}'
MyFirstApi.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var tasks = new List<TodoTask>
{
    new(1, "Learn Minimal APIs", false),
    new(2, "Build a project", false)
};

app.MapGet("/tasks", () => tasks);

app.MapGet("/tasks/{id:int}", (int id) =>
{
    var task = tasks.FirstOrDefault(t => t.Id == id);
    return task is not null ? Results.Ok(task) : Results.NotFound();
});

app.MapPost("/tasks", (CreateTaskRequest request) =>
{
    var newTask = new TodoTask(
        tasks.Count > 0 ? tasks.Max(t => t.Id) + 1 : 1,
        request.Title,
        request.Done
    );
    tasks.Add(newTask);
    return Results.Created($"/tasks/{newTask.Id}", newTask);
});

app.Run();

record TodoTask(int Id, string Title, bool Done);
record CreateTaskRequest(string Title, bool Done);

Console

You'll see the ASP.NET Core startup message with the localhost URL. The GET request returns your initial tasks as JSON. The POST request adds a new task and returns it with a 201 Created status and Location header.

Troubleshooting

Do I need to install anything besides .NET 8?

No, just the .NET 8 SDK. The SDK includes everything for building web APIs including the ASP.NET Core runtime, templates, and CLI tools. Download it from dot.net and verify with dotnet --version. You can use any editor, though VS Code or Visual Studio provide better IntelliSense.

How do I test the API without a browser?

Use curl from your terminal for quick tests. For more features, try Postman, Insomnia, or the REST Client extension in VS Code. All browsers work for GET requests. For POST/PUT/DELETE, you'll need a tool that can set request bodies and headers.

Can I return types other than JSON?

Yes, return any type you want. Use Results.Text for plain text, Results.File for downloads, or Results.Stream for binary data. Return strings directly and they serialize as plain text. JSON is the default for objects and collections, but you control the content type.

Back to Articles