OpenAPI in .NET 9+: The Shape of API Documentation Just Changed
For years, if you wanted OpenAPI documentation in ASP.NET Core, you reached for Swashbuckle. It worked well enough, but it was always a third-party dependency you had to manage and configure. With .NET 9, that story shifted—Microsoft built OpenAPI generation directly into the framework. OpenAPI in .NET 9+ now means you get spec generation out of the box, lighter middleware, and better integration with the tooling ecosystem. If you've been shipping APIs without proper documentation, or if you've felt friction every time you updated Swashbuckle, this is worth your attention.
Built-in Document Generation: No More Third-Party Middleware
The biggest shift is that Microsoft.AspNetCore.OpenApi generates the specification at build time or runtime without you installing Swashbuckle. This is a significant quality-of-life improvement.
Here's the minimal setup:
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapGet("/products/{id}", (int id) => new { Id = id, Name = "Widget" })
.WithName("GetProduct")
.WithOpenApi();
app.Run();
That's it. Call AddOpenApi() in the service container, call MapOpenApi() to expose the spec at /openapi/v1.json, and add .WithOpenApi() to any endpoint you want documented. No NuGet references to Swashbuckle, no generator configurations, no middleware ordering concerns.
The generated JSON follows OpenAPI 3.0 and 3.1 standards, so it's compatible with every tool in the ecosystem. I've tested this with frontend teams who feed it straight into code generators, and it works seamlessly.
One practical note: MapOpenApi() should stay in the development block. You don't ship the spec to production unless you have a specific reason—it's useful for internal tooling, staging environments, and local development, but publicly exposing your API structure isn't always wise.
Pick a UI: Scalar or Swagger UI
A JSON file isn't human-friendly. You need a UI. The framework doesn't ship with one, but you have solid options.
Swagger UI (Familiar, Battle-Tested)
If your teams already know Swagger UI, stick with it. Install Swashbuckle.AspNetCore.SwaggerUI (version 6.4+) and configure it to read from the built-in spec:
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "My API v1");
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwaggerUI();
}
app.Run();
This gives you the familiar Swagger UI interface at /swagger/index.html, but the spec is generated by the framework, not Swashbuckle. You're getting the lightweight generation without the full middleware stack.
Scalar (Modern, API-Focused)
Scalar is a modern alternative with a sleaner design and better mobile support. It's also lighter weight. Install the NuGet package and point it at your spec:
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.Run();
Scalar serves at /scalar/v1 by default. The interface is cleaner, request/response examples are highlighted better, and I've noticed frontend teams prefer it for interactive testing.
Which one? If your organization is invested in Swagger, stay with it. If you're starting fresh or open to change, Scalar feels more modern and performs better in my experience.
Describe Endpoints: Turn a Bare Spec into Useful Documentation
A generated spec without descriptions is useless. Three methods turn a skeleton into documentation that frontend teams actually use.
WithSummary and WithDescription
These set the title and detailed explanation in the OpenAPI document:
app.MapGet("/products/{id}", GetProductById)
.WithName("GetProduct")
.WithSummary("Fetch a single product")
.WithDescription("Retrieve full product details including inventory, pricing, and related items. Returns 404 if the product ID does not exist.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithOpenApi();
async Task GetProductById(int id, ILogger logger)
{
logger.LogInformation("Fetching product {ProductId}", id);
// implementation
return Results.Ok(new Product { Id = id, Name = "Widget" });
}
In the UI, the summary appears as the operation title, and the description shows in the details panel. This is where you explain business logic, edge cases, and retry behavior.
Produces: Document Your Response Types
Produces tells the spec generator what status codes and response types your endpoint returns. The UI displays these as tabs, and code generators use them to create typed clients:
app.MapPost("/orders", CreateOrder)
.WithSummary("Create a new order")
.WithDescription("Creates an order and queues it for fulfillment.")
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.WithOpenApi();
Now the spec includes all three response types. A frontend developer reading this knows exactly what they're working with, and a code generator produces a client method that returns one of those three outcomes.
Example: A Real Endpoint
Here's a complete, documented endpoint as I'd write it for production:
app.MapGet("/api/customers/{customerId}/invoices", GetCustomerInvoices)
.WithName("GetCustomerInvoices")
.WithSummary("List invoices for a customer")
.WithDescription(
"Returns paginated invoices for the specified customer. " +
"Results are sorted by invoice date (newest first). " +
"Only invoices visible to the authenticated user are returned.")
.WithOpenApi()
.Produces>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization();
async Task GetCustomerInvoices(
int customerId,
[FromQuery(Name = "page")] int page = 1,
[FromQuery(Name = "pageSize")] int pageSize = 20,
IInvoiceService invoiceService = null)
{
var result = await invoiceService.GetInvoicesAsync(customerId, page, pageSize);
return Results.Ok(result);
}
The UI now shows the operation, all three possible responses, authorization requirements, and query parameters. A frontend developer—or a code generator—has everything needed.
Generate Clients: Never Let Types Drift Again
The real value of OpenAPI isn't documentation. It's that you can feed the spec to a code generator and get a strongly-typed client. No more manual API wrapper classes that fall out of sync with the backend.
NSwag: Full-Featured, Mature
NSwag generates C# clients and covers every OpenAPI feature. If you have a .NET frontend (WPF, WinForms, MAUI), this is the right choice:
dotnet tool install NSwag.ConsoleCore --global
nswag openapi2csharp /input:http://localhost:5000/openapi/v1.json /output:Generated/ApiClient.cs /namespace:MyApp.Api
You get a generated ApiClient class with typed methods for every endpoint. Call it in your UI code with autocomplete and compile-time safety.
Kiota: Microsoft's Official Tool
Kiota is Microsoft's answer, designed for modern SDK generation. It supports C#, TypeScript, Python, Go, Java, and more. For projects with mixed-language teams, it's a good fit:
kiota generate -l csharp -c MyApiClient -n MyApp.Api -d ./openapi/v1.json
The generated client is lighter weight than NSwag and integrates cleanly with dependency injection.
openapi-typescript: For Frontend
If your frontend is TypeScript/React, openapi-typescript generates types and a fetch-based client:
npm install openapi-typescript
openapi-typescript http://localhost:5000/openapi/v1.json -o api-types.ts
Now your React components have typed API methods with full intellisense. When you add a new field to a backend response, TypeScript catches it immediately in the UI build.
Integration in CI/CD
The real win is automating this. Add a step to your CI pipeline that fetches the spec and regenerates clients whenever the API changes:
// .github/workflows/api-client-generation.yml
- name: Generate TypeScript Client
run: |
npm install openapi-typescript
openapi-typescript http://staging-api.internal/openapi/v1.json -o src/generated/api.ts
- name: Commit Changes
run: |
git config --global user.email "bot@company.com"
git config --global user.name "API Bot"
git add src/generated/api.ts
git commit -m "Regenerate API client" || true
git push
Every time the API spec changes, the client types update automatically. Frontend and backend stay in sync without manual work.
Common Mistakes
- Forgetting .WithOpenApi(): If you don't add this to your endpoint, it won't appear in the spec. I've seen developers spend an hour wondering why their endpoint didn't show up in the UI.
- Documenting Nothing: A bare spec with no summaries or descriptions defeats the purpose. Spend five minutes writing good descriptions. Frontend teams will thank you.
- Not Calling MapOpenApi() in the Right Place: If it's outside the development environment check, you're exposing your schema to the internet. Keep it behind
if (app.Environment.IsDevelopment())or explicit middleware authorization. - Ignoring Produces(): Without response type documentation, code generators can't create proper clients. Always specify status codes and response shapes.
- Assuming Your Spec is Always Up-to-Date: The spec is generated at runtime, not build time. If you change an endpoint but forget to restart the app, the old spec is still served. In CI/CD, build and test before generating clients.
Key Takeaways
- .NET 9's built-in OpenAPI removes the Swashbuckle dependency for spec generation, simplifying your stack and reducing middleware overhead.
- Pair the spec with Swagger UI or Scalar for interactive documentation in development environments only.
- Use
WithSummary,WithDescription, andProducesto turn a bare spec into useful documentation that frontend teams actually read. - Feed your OpenAPI spec to NSwag, Kiota, or openapi-typescript to generate typed clients, eliminating manual API wrapper classes and keeping types in sync across your stack.
- Automate client generation in your CI/CD pipeline so frontend and backend types never drift when you make API changes.






Comments
Loading comments…