Dashboard from Your .NET API (ASP.NET Core)

An ASP.NET Core minimal API endpoint returning a dashboard record beside the gauge widget it renders.

Return a record from a minimal API endpoint. System.Text.Json serializes it. That's a dashboard widget.

Roughly fifteen lines, in a project you already deploy.

The reality

In .NET the dashboard question usually resolves into one of three answers, and each has a cost that shows up later.

Build it in Blazor. Perfectly viable, and you stay in C# the whole way. But you've added a UI project to the solution: its own hosting model, its own render mode decisions, and a page that now moves through your release pipeline every time a number changes.

Buy a component suite. The commercial grid-and-chart libraries are genuinely good, and if you're already using one, its chart control is a reasonable answer. If you aren't, you're taking on a licence and a large dependency to draw six tiles.

Use the Microsoft BI estate. Power BI is strong at modelling and governance, and it is the obvious call in an organisation already standardised on it. It is a heavy answer for a four-person team that wants live numbers on a wall screen, and licensing viewers is its own conversation.

What none of them do cheaply is the small case: a handful of business numbers, always current, readable by the team, without a new project in the solution.

Your data stays in your .NET services

dashboardbase never asks for database credentials and never stores your data. It calls your endpoint over HTTPS and renders the response.

Your connection string, your DbContext, your managed identity, your internal service clients — all of it stays inside your own infrastructure. Nothing on our side can query your database, because nothing on our side knows how.

Your existing authorization still applies. If the service the endpoint calls is already scoped to a tenant, the endpoint is scoped the same way — no permission model to re-model in a reporting tool.

Define ASP.NET Core endpoints

A gauge widget takes a value and the maximum to measure it against:

{
  "title": "Quarterly target",
  "actions": [
    {
      "title": "View Goals",
      "type": "link",
      "url": "https://example.com/goals"
    }
  ],
  "data": {
    "header": {
      "title": "62 / 100",
      "subtitle": "Q2 new customers"
    },
    "value": {
      "value": 62
    },
    "maxValue": 100
  }
}
{
  "title": "Quarterly target",
  "actions": [
    {
      "title": "View Goals",
      "type": "link",
      "url": "https://example.com/goals"
    }
  ],
  "data": {
    "header": {
      "title": "62 / 100",
      "subtitle": "Q2 new customers"
    },
    "value": {
      "value": 62
    },
    "maxValue": 100
  }
}
{
  "title": "Quarterly target",
  "actions": [
    {
      "title": "View Goals",
      "type": "link",
      "url": "https://example.com/goals"
    }
  ],
  "data": {
    "header": {
      "title": "62 / 100",
      "subtitle": "Q2 new customers"
    },
    "value": {
      "value": 62
    },
    "maxValue": 100
  }
}

Records plus camelCase serialization gets you there with no attributes to sprinkle:

public record Action(string Title, string Type, string Url);

public record Header(string Title, string? Subtitle = null);

public record GaugeValue(double Value, string? Postfix = null);

public record GaugeData(Header Header, GaugeValue Value, double MaxValue);

public record Widget<T>(string Title, T Data, IEnumerable<Action>? Actions = null);
public record Action(string Title, string Type, string Url);

public record Header(string Title, string? Subtitle = null);

public record GaugeValue(double Value, string? Postfix = null);

public record GaugeData(Header Header, GaugeValue Value, double MaxValue);

public record Widget<T>(string Title, T Data, IEnumerable<Action>? Actions = null);
public record Action(string Title, string Type, string Url);

public record Header(string Title, string? Subtitle = null);

public record GaugeValue(double Value, string? Postfix = null);

public record GaugeData(Header Header, GaugeValue Value, double MaxValue);

public record Widget<T>(string Title, T Data, IEnumerable<Action>? Actions = null);

And the whole application:

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.SerializerOptions.DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull;
});

var app = builder.Build();

// Fail at startup rather than serving an unprotected endpoint.
var secret = builder.Configuration["DASHBOARDBASE_ENDPOINT_SECRET"]
    ?? throw new InvalidOperationException("DASHBOARDBASE_ENDPOINT_SECRET is not set");

app.MapGroup("/dashboard")
   .AddEndpointFilter(async (context, next) =>
   {
       var provided = context.HttpContext.Request.Headers["x-dashboardbase-secret"];
       return CryptographicOperations.FixedTimeEquals(
                  Encoding.UTF8.GetBytes(provided.ToString()),
                  Encoding.UTF8.GetBytes(secret))
           ? await next(context)
           : Results.Unauthorized();
   })
   .MapGet("/quarterly-target", async (ICustomerService customers) =>
   {
       var won = await customers.NewThisQuarterAsync();
       const int target = 100;

       return new Widget<GaugeData>(
           Title: "Quarterly target",
           Actions: [new Action("View Goals", "link", "https://example.com/goals")],
           Data: new GaugeData(
               Header: new Header($"{won} / {target}", "Q2 new customers"),
               Value: new GaugeValue(won),
               MaxValue: target));
   });

app.Run();
var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.SerializerOptions.DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull;
});

var app = builder.Build();

// Fail at startup rather than serving an unprotected endpoint.
var secret = builder.Configuration["DASHBOARDBASE_ENDPOINT_SECRET"]
    ?? throw new InvalidOperationException("DASHBOARDBASE_ENDPOINT_SECRET is not set");

app.MapGroup("/dashboard")
   .AddEndpointFilter(async (context, next) =>
   {
       var provided = context.HttpContext.Request.Headers["x-dashboardbase-secret"];
       return CryptographicOperations.FixedTimeEquals(
                  Encoding.UTF8.GetBytes(provided.ToString()),
                  Encoding.UTF8.GetBytes(secret))
           ? await next(context)
           : Results.Unauthorized();
   })
   .MapGet("/quarterly-target", async (ICustomerService customers) =>
   {
       var won = await customers.NewThisQuarterAsync();
       const int target = 100;

       return new Widget<GaugeData>(
           Title: "Quarterly target",
           Actions: [new Action("View Goals", "link", "https://example.com/goals")],
           Data: new GaugeData(
               Header: new Header($"{won} / {target}", "Q2 new customers"),
               Value: new GaugeValue(won),
               MaxValue: target));
   });

app.Run();
var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.SerializerOptions.DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull;
});

var app = builder.Build();

// Fail at startup rather than serving an unprotected endpoint.
var secret = builder.Configuration["DASHBOARDBASE_ENDPOINT_SECRET"]
    ?? throw new InvalidOperationException("DASHBOARDBASE_ENDPOINT_SECRET is not set");

app.MapGroup("/dashboard")
   .AddEndpointFilter(async (context, next) =>
   {
       var provided = context.HttpContext.Request.Headers["x-dashboardbase-secret"];
       return CryptographicOperations.FixedTimeEquals(
                  Encoding.UTF8.GetBytes(provided.ToString()),
                  Encoding.UTF8.GetBytes(secret))
           ? await next(context)
           : Results.Unauthorized();
   })
   .MapGet("/quarterly-target", async (ICustomerService customers) =>
   {
       var won = await customers.NewThisQuarterAsync();
       const int target = 100;

       return new Widget<GaugeData>(
           Title: "Quarterly target",
           Actions: [new Action("View Goals", "link", "https://example.com/goals")],
           Data: new GaugeData(
               Header: new Header($"{won} / {target}", "Q2 new customers"),
               Value: new GaugeValue(won),
               MaxValue: target));
   });

app.Run();

Paste the URL into a Gauge widget and it renders. Every other widget type is the same Widget<T> with a different Data record.

That x-dashboardbase-secret header is the workspace endpoint secret. Every workspace has one and dashboardbase sends it on every request to your endpoints, automatically — there is nothing to configure beyond the comparison above. It authenticates us to you; it is not an API key for calling dashboardbase. Rotating it keeps the previous value working for 24 hours, so a rotation never takes the board down.

Controller-based MVC works identically — the same records returned from an action method, with the filter registered as an IAsyncActionFilter.

Charts follow the same envelope with Labels and Datasets inside Data, where each point is a record with a Value.

Generate .NET endpoints faster

  • The dashboardbase Skill is free and open source. It teaches Claude Code, Cursor or any skills-capable agent the whole contract, so generated endpoints and records land on the right shape rather than close to it.

  • In-app prompt generation produces a copy-pasteable prompt for whatever AI tool you already use — per widget, or one covering a whole dashboard.

Both hand you a reviewable draft you run yourself. The prompt is generated in your browser and nothing executes on our side.

Then paste a response into the endpoint validator to confirm the widget can render it. No account needed.

Where this isn't the right fit

  • You're already standardised on the Microsoft BI stack. If the modelling, governance and Excel integration are load-bearing, stay there.

  • The dashboard is a product feature. Customer-facing analytics belongs in your own Blazor or MVC app.

  • Ad-hoc exploration. There's no query builder here. This renders numbers you've already chosen to watch.

  • Write actions. Approving, editing, triggering — that's an internal-tools problem, and these boards are read-only by design.

Map one endpoint and see

Add the endpoint above to a service you already run, paste the URL into a widget, and judge it from something real. The board reads on your phone in the native iOS and Android apps.

Worth reading next: how to build a dashboard for all three routes side by side, and dashboardbase vs BI tools vs building it yourself if the BI question is the live one on your team.