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 never stores your data. 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 — headers 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": {
"headers": [
{ "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" }
]
]
}
}{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://example.com/customers"
}
],
"data": {
"headers": [
{ "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" }
]
]
}
}{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://example.com/customers"
}
],
"data": {
"headers": [
{ "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 HeaderCell(String text, int width) {}
public record TableData(List<HeaderCell> headers, 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) {}@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 HeaderCell(String text, int width) {}
public record TableData(List<HeaderCell> headers, 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) {}@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 HeaderCell(String text, int width) {}
public record TableData(List<HeaderCell> headers, 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 headers = List.of(
new HeaderCell("Customer", 45),
new HeaderCell("Plan", 30),
new HeaderCell("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(headers, rows)
);
}
}@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 headers = List.of(
new HeaderCell("Customer", 45),
new HeaderCell("Plan", 30),
new HeaderCell("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(headers, rows)
);
}
}@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 headers = List.of(
new HeaderCell("Customer", 45),
new HeaderCell("Plan", 30),
new HeaderCell("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(headers, 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;
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);
}
}@Component
public class DashboardbaseSecretFilter extends OncePerRequestFilter {
private final String secret;
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);
}
}@Component
public class DashboardbaseSecretFilter extends OncePerRequestFilter {
private final String secret;
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 and open source. 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.