Dashboard from Your Java API (Spring Boot)

Return a record from a @RestController. Jackson serializes it. That's a dashboard widget.
No frontend module, no bundler in the Maven build, no second artifact to deploy.
The reality
Java has no shortage of ways to put numbers on a screen, and each one asks for something.
Micrometer into Prometheus into Grafana is the standard path, and it's excellent for what it's for. It's also a metrics pipeline: your business numbers become counters and gauges, which is a strange shape for "MRR" or "top ten accounts by usage," and the answer to "what was it last quarter" depends on your retention config.
Component suites and admin frameworks — Vaadin, a JSF-era dashboard, a reporting library like JasperReports — put the rendering back inside your application. Now the dashboard is part of the deployable: it goes through your release process, your security scanning, and your dependency upgrades, for a page that displays six numbers.
Spring Boot Admin covers actuator health and JVM internals well, and was never meant to hold revenue.
The gap is consistent: business numbers, read by people, without adding a rendering stack to a build that's already long enough.
Your data stays in your Java services
dashboardbase never asks for database credentials and doesn’t copy your data into a warehouse of its own. It calls your endpoint over HTTPS and renders what comes back.
Your DataSource, your JPA entities, your connection pool, your service credentials — all of it stays behind your own firewall. There is nothing on our side that could query your database, because nothing on our side knows how. For teams where a JDBC URL leaving the estate is a compliance conversation rather than a config change, that's usually the deciding argument.
Your existing security context still applies too. A @PreAuthorize on the service the controller calls works exactly as it does everywhere else — you're not re-modelling authorization inside a reporting tool.
Define Spring Boot endpoints
Records map onto the contract almost exactly. Here's a table widget — columns with widths, and rows of cells where every cell is an object, never a bare string:
{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://example.com/customers"
}
],
"data": {
"columns": [
{ "text": "Customer", "width": 45 },
{ "text": "Plan", "width": 30 },
{ "text": "MRR", "width": 25 }
],
"rows": [
[
{ "text": "Acme Corp", "link": "https://example.com/customers/acme" },
{ "text": "Enterprise", "badge": { "text": "Enterprise", "color": "Dark" } },
{ "text": "$1,200" }
],
[
{ "text": "Globex", "link": "https://example.com/customers/globex" },
{ "text": "Pro", "badge": { "text": "Pro", "color": "Success" } },
{ "text": "$480" }
]
]
}
}The records, with @JsonInclude so the optional fields drop out when unset:
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Badge(String text, String icon, String color) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Cell(String text, String link, Badge badge) {
public static Cell of(String text) {
return new Cell(text, null, null);
}
}
public record Column(String text, int width) {}
public record TableData(List<Column> columns, List<List<Cell>> rows) {}
public record Action(String title, String type, String url) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Widget<T>(String title, List<Action> actions, T data) {}And the controller:
@RestController
@RequestMapping("/dashboard")
public class DashboardController {
private final CustomerService customers;
public DashboardController(CustomerService customers) {
this.customers = customers;
}
@GetMapping("/top-customers")
public Widget<TableData> topCustomers() {
var columns = List.of(
new Column("Customer", 45),
new Column("Plan", 30),
new Column("MRR", 25)
);
var rows = customers.topByMrr(10).stream()
.map(c -> List.of(
new Cell(c.name(), "https://example.com/customers/" + c.id(), null),
new Cell(c.plan(), null, new Badge(c.plan(), null, "Success")),
Cell.of(formatCurrency(c.mrrCents()))
))
.toList();
return new Widget<>(
"Top customers",
List.of(new Action("View All", "link", "https://example.com/customers")),
new TableData(columns, rows)
);
}
}Deploy it, paste the URL into a Table widget, and it renders. Every other widget type is the same Widget<T> with a different data record.
Securing it is one filter. Every workspace has an endpoint secret, and dashboardbase sends it as an x-dashboardbase-secret header on every request to your endpoints, automatically. It authenticates us to you — it is not an API key for calling dashboardbase:
@Component
public class DashboardbaseSecretFilter extends OncePerRequestFilter {
private final String secret;
// Fail at startup rather than serving an unprotected endpoint.
public DashboardbaseSecretFilter(
@Value("${DASHBOARDBASE_ENDPOINT_SECRET}") String secret) {
this.secret = Objects.requireNonNull(secret, "DASHBOARDBASE_ENDPOINT_SECRET is not set");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
var provided = request.getHeader("x-dashboardbase-secret");
if (provided == null || !MessageDigest.isEqual(
provided.getBytes(UTF_8), secret.getBytes(UTF_8))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
chain.doFilter(request, response);
}
}Rotating the secret keeps the previous value working for 24 hours, so a rotation doesn't take the board down. Quarkus and Jakarta REST work the same way — a ContainerRequestFilter instead of OncePerRequestFilter, and the same records.
Generate Java endpoints faster
- The dashboardbase Skill is free to install. It teaches Claude Code, Cursor or any skills-capable agent the full contract, so generated controllers and records land on the right shape instead of near it.
- In-app prompt generation produces a copy-pasteable prompt for whatever AI tool you already use, per widget or for a whole dashboard.
Both give 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 and confirm the widget renders it — no account required.
Where this isn't the right fit
- JVM and infrastructure metrics. Heap, GC, thread pools, actuator health — Micrometer and Grafana own that, and should keep it.
- Formal reporting. Paginated, printable, archived documents are a reporting-engine job. This renders a live board.
- Ad-hoc analysis over a warehouse. A semantic model queried many ways is a BI problem, and endpoints don't make it not one.
- Write actions. Read-only by design.
Add one controller and see
Drop the controller above into a service you already run, paste the URL into a widget, and decide from something real. The board reads on your phone in the native iOS and Android apps.
Worth reading next: build vs buy: should you build your own dashboards? for the ownership maths, and API-first dashboards for why the contract belongs on your side.