Generate Beautiful API Docs with OpenAPI & Scalar
Set up OpenAPI in .NET 9 with the built-in AddOpenApi(), replace Swagger UI with Scalar, add XML doc comments, customize with IOperationFilter, version your API docs, and lock down the docs endpoint in production.
Built-In OpenAPI Support (.NET 9+)
Starting with .NET 9, Microsoft.AspNetCore.OpenApi is the first-party way to generate OpenAPI documents. No more reaching for Swashbuckle by default.
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCorevar builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
document.Info = new OpenApiInfo
{
Title = "Orders API",
Version = "v1",
Description = "Manage orders, customers, and products.",
Contact = new OpenApiContact
{
Name = "API Support",
Email = "support@example.com"
}
};
return Task.CompletedTask;
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // serves /openapi/v1.json
app.MapScalarApiReference(); // serves /scalar/v1
}
app.Run();Visit /scalar/v1 in development for the Scalar UI.
Scalar — A Better UI
Scalar is a modern, fast alternative to Swagger UI. It renders your OpenAPI spec with a clean UI, supports auth flows, and generates client code snippets in multiple languages.
app.MapScalarApiReference(options =>
{
options.Title = "Orders API";
options.Theme = ScalarTheme.Purple;
options.DefaultHttpClient = new(ScalarTarget.CSharp, ScalarClient.HttpClient);
options.Authentication = new ScalarAuthenticationOptions
{
PreferredSecurityScheme = "Bearer"
};
});Available themes: Default, Alternate, Moon, Purple, Solarized, BluePlanet, Saturn, Kepler, Mars, DeepSpace, None.
XML Comments for Endpoint Documentation
XML doc comments on your request/response models and handlers flow directly into the OpenAPI spec.
Enable XML output in your .csproj:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>Register it:
builder.Services.AddOpenApi(options =>
{
options.AddSchemaTransformer((schema, context, ct) =>
{
// XML comments on models are picked up automatically
return Task.CompletedTask;
});
});Document your models:
/// <summary>Request to create a new order.</summary>
public record CreateOrderRequest(
/// <summary>ID of the customer placing the order.</summary>
int CustomerId,
/// <summary>Line items in the order.</summary>
List<OrderLineRequest> Lines);
/// <summary>A line item in an order.</summary>
public record OrderLineRequest(
/// <summary>Product ID.</summary>
int ProductId,
/// <summary>Quantity ordered. Must be at least 1.</summary>
int Quantity);Document your endpoints:
/// <summary>Get an order by ID.</summary>
/// <param name="id">The order ID.</param>
/// <response code="200">Returns the order.</response>
/// <response code="404">Order not found.</response>
[HttpGet("{id}")]
[ProducesResponseType<Order>(200)]
[ProducesResponseType<ProblemDetails>(404)]
public async Task<IActionResult> GetOrder(int id) { }IOperationFilter — Custom Headers and Auth in Docs
An IOperationFilter lets you modify every operation in the OpenAPI document programmatically. The classic use case: add a security requirement to every endpoint that has [Authorize].
public class BearerAuthOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var hasAuthorize = context.MethodInfo
.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.Any()
|| (context.MethodInfo.DeclaringType?
.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.Any() ?? false);
if (!hasAuthorize) return;
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
}
}Register it and add the Bearer scheme definition:
builder.Services.AddSwaggerGen(options =>
{
options.OperationFilter<BearerAuthOperationFilter>();
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Enter your JWT token."
});
});Versioned API Docs
When you have multiple API versions, generate a separate OpenAPI document for each.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
});
builder.Services.AddOpenApi("v1", options =>
{
options.AddDocumentTransformer((doc, ctx, ct) =>
{
doc.Info.Title = "Orders API v1";
doc.Info.Version = "1.0";
return Task.CompletedTask;
});
});
builder.Services.AddOpenApi("v2", options =>
{
options.AddDocumentTransformer((doc, ctx, ct) =>
{
doc.Info.Title = "Orders API v2";
doc.Info.Version = "2.0";
return Task.CompletedTask;
});
});app.MapOpenApi("/openapi/{documentName}.json");
app.MapScalarApiReference(options =>
{
options.Servers = [];
options.AddDocument("v1", "/openapi/v1.json", "Orders API v1");
options.AddDocument("v2", "/openapi/v2.json", "Orders API v2");
});Securing the Docs in Production
Never expose your OpenAPI spec or Scalar UI in production without auth.
Option 1 — Environment guard (simplest)
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}Option 2 — Require authorization in all environments
app.MapOpenApi()
.RequireAuthorization("DocsPolicy");
app.MapScalarApiReference()
.RequireAuthorization("DocsPolicy");
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("DocsPolicy", policy =>
policy.RequireRole("Developer", "Admin"));
});Option 3 — Static API key via middleware
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/scalar"),
branch => branch.Use(async (ctx, next) =>
{
if (!ctx.Request.Headers.TryGetValue("X-Docs-Key", out var key)
|| key != builder.Configuration["DocsKey"])
{
ctx.Response.StatusCode = 401;
return;
}
await next(ctx);
}));Option 1 is fine for most teams. Use option 2 or 3 if your staging environment is publicly reachable.
Minimal API OpenAPI Metadata
When using Minimal APIs, attach metadata directly to endpoints:
app.MapGet("/orders/{id}", GetOrderById)
.WithName("GetOrderById")
.WithSummary("Get an order by ID")
.WithDescription("Returns the full order including line items.")
.WithTags("Orders")
.Produces<Order>(200)
.ProducesProblem(404)
.ProducesProblem(401)
.RequireAuthorization()
.WithOpenApi(op =>
{
op.Parameters[0].Description = "The unique order identifier";
return op;
});The .WithOpenApi() overload accepts a transformer delegate for one-off customizations that don't warrant a full IOperationFilter.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.