DEV Community

JF Meyers
JF Meyers

Posted on

Swashbuckle Is Dead. Here's How to Migrate to Scalar in .NET 10.

Why Swashbuckle is no longer the right choice

Swashbuckle.AspNetCore was the de facto Swagger UI for .NET for years.
Today it has three problems that are hard to work around:

Problem Impact
No OpenAPI 3.1 support Can't use JSON Schema features (const, webhooks, $ref siblings)
Upstream archived No security patches, no .NET compatibility updates
Bypasses native metadata Misses .Produces<T>(), .ProducesProblem() on Minimal APIs

Microsoft removed Swashbuckle from the dotnet new webapi template in .NET 9 and
replaced it with Microsoft.AspNetCore.OpenApi. That was the official signal.


What replaced it

Two components, one stack:

  • Microsoft.AspNetCore.OpenApi — first-party document generator, ships with
    .NET 9+. Hooks directly into the ASP.NET Core endpoint data source. No XML comment
    parsing, no reflection gymnastics. Generates OpenAPI 3.1 from the metadata you
    already declared.

  • Scalar.AspNetCore — open-source API reference portal (MIT). Reads any
    OpenAPI 3.1 document and renders an interactive UI with OAuth2/PKCE auth, code
    generation in 15+ languages, and a clean dark/light mode design.

They are independent. You can use the native generator with a different UI, or Scalar
with a document from NSwag. In practice, using them together is the obvious choice.


The migration — step by step

1. Remove Swashbuckle

dotnet remove package Swashbuckle.AspNetCore
Enter fullscreen mode Exit fullscreen mode

Also remove any Swashbuckle.AspNetCore.* packages (annotations, filters, etc.).
Delete the AddSwaggerGen and UseSwagger / UseSwaggerUI calls from Program.cs.

2. Add the new packages

dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore
Enter fullscreen mode Exit fullscreen mode

3. Register document generation

using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Native OpenAPI document generation
builder.Services.AddOpenApi("v1", options =>
{
    options.AddDocumentTransformer((doc, _, _) =>
    {
        doc.Info = new OpenApiInfo
        {
            Title = "Bookings API",
            Version = "1.0",
            Description = "Hotel booking management API.",
        };
        return Task.CompletedTask;
    });
});

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

4. Map the endpoints

if (app.Environment.IsDevelopment())
{
    // Serves /openapi/v1.json
    app.MapOpenApi("/openapi/v1.json");

    // Serves Scalar UI at /scalar
    app.MapScalarApiReference();
}

await app.RunAsync();
Enter fullscreen mode Exit fullscreen mode

That is the minimum viable setup. Navigate to /scalar and you have an interactive
API explorer backed by your live OpenAPI 3.1 document.


Adding JWT Bearer authentication

Swashbuckle had AddSecurityDefinition / AddSecurityRequirement. The native pipeline
uses document transformers.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi.Models;

internal sealed class BearerSecuritySchemeTransformer(
    IAuthenticationSchemeProvider authenticationSchemeProvider)
    : IOpenApiDocumentTransformer
{
    public async Task TransformAsync(
        OpenApiDocument document,
        OpenApiDocumentTransformerContext context,
        CancellationToken cancellationToken)
    {
        IEnumerable<AuthenticationScheme> schemes =
            await authenticationSchemeProvider.GetAllSchemesAsync();

        if (!schemes.Any(s => s.Name == "Bearer"))
        {
            return;
        }

        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, OpenApiSecurityScheme>();
        document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            In = ParameterLocation.Header,
            Description = "Enter your JWT token.",
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it before AddOpenApi:

builder.Services.AddTransient<BearerSecuritySchemeTransformer>();

builder.Services.AddOpenApi("v1", options =>
{
    options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
    // ...
});
Enter fullscreen mode Exit fullscreen mode

Note: the transformer must be registered in DI before AddOpenApi is called,
or the DI resolution will fail at startup.


OAuth2 Authorization Code + PKCE

If your API is protected by an OAuth2 provider (Keycloak, Entra ID, Auth0), you can
configure Scalar to authenticate directly in the browser — no Postman needed.

app.MapScalarApiReference(opts =>
{
    opts.WithTitle("Bookings API");
    opts.AddAuthorizationCodeFlow("OAuth2", flow =>
    {
        flow.WithClientId("bookings-frontend-client")
            .WithSelectedScopes("openid", "profile")
            .WithPkce(Pkce.Sha256);
    });
});
Enter fullscreen mode Exit fullscreen mode

Scalar shows a Sign in button. The flow runs entirely in the browser. The resulting
token is automatically attached to every test request.

Use a public (frontend) client. Never configure your backend confidential client
here — it would expose the client secret in a browser context.


Multi-version APIs

One document per major version. Each call to AddOpenApi registers an independent
document:

builder.Services.AddOpenApi("v1", options => { /* ... */ });
builder.Services.AddOpenApi("v2", options => { /* ... */ });

app.MapOpenApi("/openapi/v1.json");
app.MapOpenApi("/openapi/v2.json");

app.MapScalarApiReference(opts =>
{
    opts.WithEndpointPrefix("/scalar/{documentName}");
});
Enter fullscreen mode Exit fullscreen mode

The default Scalar route (/scalar/{documentName}) handles version switching in the UI.


Common gotchas

.Produces<T>() is required — there is no fallback inference

Swashbuckle could sometimes infer response types by inspecting the action return type.
The native pipeline does not. If you omit .Produces<T>(), the operation has no
response schema.

// Missing .Produces<BookingResponse>() → no response type in the OpenAPI document
app.MapGet("/bookings/{id:guid}", GetBookingAsync)
    .WithName("GetBooking")
    .WithSummary("Returns a booking by ID.")
    .ProducesProblem(StatusCodes.Status404NotFound);
    // .Produces<BookingResponse>() ← you must add this

// Correct
app.MapGet("/bookings/{id:guid}", GetBookingAsync)
    .WithName("GetBooking")
    .WithSummary("Returns a booking by ID.")
    .Produces<BookingResponse>()
    .ProducesProblem(StatusCodes.Status404NotFound);
Enter fullscreen mode Exit fullscreen mode

Use TypedResults, not Results

Results.Ok(value) returns IResult at compile time — the native pipeline cannot
determine the response type. TypedResults.Ok(value) returns Ok<T> — the type is
known statically and shows up in the OpenAPI schema automatically.

// BAD — type lost at compile time
return Results.Ok(booking);

// GOOD — type preserved, OpenAPI schema complete
return TypedResults.Ok(booking);
Enter fullscreen mode Exit fullscreen mode

Protect the docs endpoint in non-development environments

app.MapOpenApi() and app.MapScalarApiReference() create unauthenticated endpoints
by default. In staging or production, protect them explicitly:

app.MapOpenApi("/openapi/v1.json")
   .RequireAuthorization("InternalDeveloper");

app.MapScalarApiReference()
   .RequireAuthorization("InternalDeveloper");
Enter fullscreen mode Exit fullscreen mode

Or gate the entire block with IsDevelopment() and add explicit staging config.

Transformers run in registration order

Document transformers execute in the order they are registered. If transformer B
depends on data set by transformer A, register A first. This tripped me up with a
security scheme transformer that assumed doc.Components was already initialized.


Scalar vs Swagger UI — feature comparison

Feature Swagger UI (Swashbuckle) Scalar
OpenAPI version 2.0 / 3.0 3.1
.NET 9/10 native pipeline No Yes
Interactive request builder Yes Yes
OAuth2 PKCE in browser Partial Yes, first-class
Code generation No 15+ languages
Dark mode Third-party themes Built-in
Maintenance status Archived Active (MIT)

If you use a framework that already handles this

Rolling the transformer pipeline manually gets repetitive across projects. If you work
with the open-source Granit framework for .NET, all of
this is pre-wired in the Granit.Http.ApiDocumentation module: security schemes,
OAuth2 PKCE, multi-version documents, and production protection are all configured
from a single appsettings.json section.

{
  "ApiDocumentation": {
    "Title": "Bookings API",
    "MajorVersions": [1, 2],
    "AuthorizationPolicy": "InternalDeveloper",
    "OAuth2": {
      "AuthorizationUrl": "https://auth.example.com/.../auth",
      "TokenUrl": "https://auth.example.com/.../token",
      "ClientId": "bookings-frontend-client"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Worth a look if you are building a new modular .NET backend from scratch.


Summary

The migration from Swashbuckle to Scalar on .NET 10 is straightforward once you know
the transformer pattern. The short version:

  1. Remove Swashbuckle.AspNetCore, add Microsoft.AspNetCore.OpenApi + Scalar.AspNetCore
  2. Replace AddSwaggerGen with AddOpenApi + document transformer for metadata
  3. Replace UseSwagger / UseSwaggerUI with MapOpenApi + MapScalarApiReference
  4. Switch all handlers from Results.* to TypedResults.*
  5. Add .Produces<T>() explicitly on every endpoint
  6. Protect the docs endpoints outside of development

The result: OpenAPI 3.1, a modern interactive UI, native OAuth2 PKCE, and no more
dependency on an archived package.


Have questions or migration tips to share? Drop them in the comments.

Top comments (0)