← All posts

6 min read

Building a .NET API for a Flutter App with ASP.NET Core Minimal APIs

How I build a .NET backend for a Flutter app with ASP.NET Core minimal APIs: typed results, EF Core, JWT auth, ProblemDetails errors, pagination and Dart models.

  • Flutter
  • .NET
  • Backend
  • API
Cover illustration for Building a .NET API for a Flutter App with ASP.NET Core Minimal APIs

Most Flutter tutorials end at the HTTP call. The backend is someone else’s problem: a URL that returns JSON, ideally the JSON you expected.

When you’re the one writing both sides, that changes quickly. I built the backend for Dent Shop Manager in .NET (data models, REST APIs and authentication) behind a Flutter client, and the thing I learned is that a backend can be “correct” and still be painful to consume from a mobile app. Error shapes that change per endpoint, lists without pagination, breaking changes shipped to clients that can’t update instantly.

This is how I’d set up an ASP.NET Core minimal API today with a Flutter app as its main client.

Project shape

Minimal APIs scale fine past a toy project as long as you don’t put everything in Program.cs. I group by feature:

Api/
  Program.cs
  Data/AppDbContext.cs
  Features/
    Customers/
      CustomerEndpoints.cs
      CustomerDtos.cs
    Auth/
      AuthEndpoints.cs

Each feature exposes one extension method that maps its routes. Program.cs stays short:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();

app.MapOpenApi();

var v1 = app.MapGroup("/api/v1").RequireAuthorization();
v1.MapCustomerEndpoints();

app.MapGroup("/api/v1/auth").MapAuthEndpoints(); // login/refresh stay anonymous

app.Run();

AddOpenApi and MapOpenApi come from Microsoft.AspNetCore.OpenApi, built into the templates since .NET 9. On .NET 8 you’d use Swashbuckle instead.

Endpoints with typed results

TypedResults plus a Results<...> return type gives you two things: the compiler checks you only return the responses you declared, and the OpenAPI document lists every status code accurately. That second part matters when you generate client code from it.

public static class CustomerEndpoints
{
    public static RouteGroupBuilder MapCustomerEndpoints(this RouteGroupBuilder group)
    {
        var customers = group.MapGroup("/customers");
        customers.MapGet("/{id:int}", GetById);
        customers.MapGet("/", List);
        customers.MapPost("/", Create);
        return group;
    }

    static async Task<Results<Ok<CustomerDto>, NotFound>> GetById(int id, AppDbContext db)
    {
        var customer = await db.Customers
            .AsNoTracking()
            .Where(c => c.Id == id)
            .Select(c => new CustomerDto(c.Id, c.Name, c.Email, c.UpdatedAt))
            .FirstOrDefaultAsync();

        return customer is null ? TypedResults.NotFound() : TypedResults.Ok(customer);
    }
}

Notice the projection into a DTO. Never return EF entities directly. You’ll leak columns you didn’t mean to expose, trigger lazy-loading surprises, and couple your database schema to every installed copy of the app.

EF Core habits that keep mobile clients fast

A few rules I follow for an API that phones will call over slow networks:

  • AsNoTracking() on reads. You’re not going to modify the entities, so don’t pay for change tracking.
  • Project with Select. Fetch only the columns the DTO needs.
  • Use migrations (dotnet ef migrations add) and review the generated SQL before it touches production.
  • Store timestamps in UTC and send them as ISO 8601. Let the app format them for the user’s locale.

Validation and errors the app can map

The most valuable decision on the API side is having one error shape everywhere. ASP.NET Core already has one: ProblemDetails (RFC 9457). AddProblemDetails() plus UseExceptionHandler() means even unhandled exceptions come back as ProblemDetails JSON instead of an HTML page.

For validation, return ValidationProblem with field-keyed errors:

static async Task<Results<Created<CustomerDto>, ValidationProblem>> Create(
    CreateCustomerRequest req, AppDbContext db)
{
    var errors = new Dictionary<string, string[]>();
    if (string.IsNullOrWhiteSpace(req.Name))
        errors["name"] = ["Name is required."];
    if (!string.IsNullOrEmpty(req.Email) && !req.Email.Contains('@'))
        errors["email"] = ["Email looks invalid."];
    if (errors.Count > 0)
        return TypedResults.ValidationProblem(errors);

    var entity = new Customer { Name = req.Name.Trim(), Email = req.Email };
    db.Customers.Add(entity);
    await db.SaveChangesAsync();

    var dto = new CustomerDto(entity.Id, entity.Name, entity.Email, entity.UpdatedAt);
    return TypedResults.Created($"/api/v1/customers/{entity.Id}", dto);
}

For bigger request models I’d use FluentValidation or the built-in minimal API validation in newer .NET versions, but the output shape should stay the same.

On the Flutter side, one small class handles every error the API can return:

class ApiProblem implements Exception {
  ApiProblem({required this.status, this.title, this.fieldErrors = const {}});

  final int status;
  final String? title;
  final Map<String, List<String>> fieldErrors;

  factory ApiProblem.fromJson(Map<String, dynamic> json) {
    final raw = (json['errors'] as Map<String, dynamic>?) ?? {};
    return ApiProblem(
      status: json['status'] as int? ?? 500,
      title: json['title'] as String?,
      fieldErrors: raw.map((k, v) => MapEntry(k, List<String>.from(v as List))),
    );
  }

  String? errorFor(String field) => fieldErrors[field]?.first;
}

The form shows problem.errorFor('email') under the email field. A 401 goes to the auth layer, a 5xx shows a generic retry message. No string-matching on error text. If you’re building those forms, I wrote more about wiring server errors into them in better forms in Flutter.

JWT bearer auth

AddJwtBearer() with no arguments reads its settings from the Authentication:Schemes:Bearer configuration section in recent .NET versions, which keeps signing details out of code. Whatever you configure, validate issuer, audience, lifetime and signing key, and keep access tokens short-lived with a refresh endpoint. The client half of that (secure storage, a refresh-once interceptor) is in production-ready authentication in Flutter.

Authorization belongs on the server, per resource. “Is this user signed in?” isn’t enough. Check whether this user is allowed to see this customer, usually by filtering every query by tenant or owner ID.

Pagination from day one

Every list endpoint takes a page size, with a sensible default and a hard maximum:

static async Task<Ok<PagedResult<CustomerDto>>> List(
    AppDbContext db, int page = 1, int pageSize = 20)
{
    pageSize = Math.Clamp(pageSize, 1, 100);
    page = Math.Max(page, 1);

    var query = db.Customers.AsNoTracking().OrderBy(c => c.Name);
    var total = await query.CountAsync();
    var items = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(c => new CustomerDto(c.Id, c.Name, c.Email, c.UpdatedAt))
        .ToListAsync();

    return TypedResults.Ok(new PagedResult<CustomerDto>(items, page, pageSize, total));
}

public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total);

Offset pagination is fine for admin-style lists. For feeds where rows are inserted constantly, switch to cursor pagination (?after=<lastId>) so items don’t shift between pages. Always order by something stable.

Versioning: old apps live forever

A web frontend updates when you deploy. A mobile app updates when the user feels like it. Some never do. So treat your API contract as public:

  • Prefix routes with a version (/api/v1) from the start, even if you never need v2.
  • Additive changes (new optional fields, new endpoints) are safe. Renaming or removing fields isn’t.
  • When you do need a breaking change, add /api/v2 and keep v1 running until analytics says it’s quiet. The Asp.Versioning packages help if you need header- or query-based versions.
  • Pair this with a minimum supported version check in the app, so you can eventually force very old builds to update.

OpenAPI to Dart models

With typed results, the generated OpenAPI document is accurate enough to drive code generation. You can feed it to openapi-generator’s Dart/dio generator and get a full client.

Honestly, on small and medium projects I often handwrite the Dart models instead, using json_serializable or plain fromJson factories, and use the OpenAPI document as the reference. Generated clients are verbose and opinionated about how you use dio; handwritten models are easier to read. The rule of thumb I use: generate when there are many endpoints or several client teams, handwrite when it’s one app and you own both sides.

CORS is for browsers only

Native mobile apps don’t enforce CORS. It’s a browser mechanism. You only need it if the same API serves Flutter web or another web frontend, and then only for those specific origins:

builder.Services.AddCors(o => o.AddPolicy("web", p => p
    .WithOrigins("https://app.example.com")
    .AllowAnyHeader()
    .AllowAnyMethod()));
// ...
app.UseCors("web");

Never ship AllowAnyOrigin() with credentials “just to make the error go away.”

Deploying to Azure App Service

For a single API, Azure App Service is the low-effort option: create a Linux web app on a .NET runtime stack, put connection strings and JWT settings in the app’s configuration (or Key Vault references), turn on HTTPS only, and deploy from a pipeline. Enable health checks with builder.Services.AddHealthChecks() and app.MapHealthChecks("/health") so the platform can tell when an instance is unhealthy. If your app builds already live in Azure DevOps, the same pipelines can deploy the API, which I touch on in Flutter CI/CD with Azure DevOps.

Takeaways

  • Organize minimal APIs by feature, with MapGroup and one mapping method per feature.
  • Use TypedResults and Results<...> so the compiler and the OpenAPI document both know your responses.
  • Return DTOs, never entities, and paginate every list with a hard maximum.
  • Use ProblemDetails for every error so the Flutter app needs exactly one error parser.
  • Version from the start; old app builds will call your API for a long time.
  • CORS only matters for web clients. Authorization always matters.

If you need a Flutter app and the .NET backend behind it built by one person who thinks about both ends, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).