The Misk Admin Dashboard: Server-Rendered with Hotwire and Tailwind
Series: Building Production Services with Misk — Part 24 of 24
Every service grows an internal surface eventually — a place where an on-call engineer flips a feature flag, a support agent looks up an account, or you stare at the dependency graph trying to remember what calls what. You can build that as a bespoke React app and pay the SPA tax forever, or you can use what’s already in the box. The misk admin dashboard is that box: a built-in operator console, mounted at /_admin/, that ships with config, database, Guice, service-graph, and web-actions tabs out of the gate — and lets you add your own. One thing up front, because it’ll save you a wrong turn: the old misk-web React dashboard is deprecated. Misk’s own README calls it “a now deprecated React framework used for the Misk Admin Dashboard v1.” The current stack is server-rendered kotlinx.html, with Hotwire and Tailwind for the dynamic bits. That’s what we’re building on.
What the admin dashboard is
A Misk dashboard is a navbar plus a set of tabs, all gated behind an access annotation. The container gives you two dashboards by convention:
- The admin dashboard (
AdminDashboard) — the operator console at/_admin/, for engineers. InstallingAdminDashboardModulebrings the standard tabs: config, database, Guice bindings, the service graph, and web actions. - A support dashboard — a separate dashboard you define for a different audience (customer support, ops) with its own access annotation, so support staff see support tools and not the JVM internals.
The split is the design, not an accident. The exemplar service defines its own SupportDashboard annotation alongside the framework’s AdminDashboard, and each tab is bound to one dashboard with one access annotation. Admin and support are just two instances of the same machinery — a dashboard annotation, an access annotation, a home URL, and tabs — pointed at different people.
The rendering stack: kotlinx.html + Hotwire + Tailwind
Here’s the part that surprises people coming from a JS-framework world. There is no client-side framework. A tab is a WebAction that returns a String of HTML, built type-safely in Kotlin:
@Singleton
class SupportDashboardIndexAction @Inject constructor(
private val dashboardPageLayout: DashboardPageLayout,
) : WebAction {
@Get("/support/")
@ResponseContentType(MediaTypes.TEXT_HTML)
@SupportDashboardAccess
fun get(): String =
dashboardPageLayout.newBuilder().build { appName, _, _ ->
h1 { +"""This is a custom Support dashboard for $appName""" }
}
}
That h1 { ... } is kotlinx.html — a Kotlin DSL that builds an HTML tree and serializes it to a string. No templates, no .html files, no string concatenation. You get the compiler checking your markup, autocomplete on tags and attributes, and the full power of Kotlin (loops, when, injected services) right inside the view. Two modules carry this:
- misk-tailwind — a “type safe interface for frontend UI with Tailwind CSS v3 and kotlinx.html.” You write Tailwind utility classes as plain strings on your tags (
div("rounded-md bg-blue-50 p-4")), and the layout wires the stylesheet in. - misk-hotwire — a “type safe interface for building backends for Hotwire Turbo frontend UI with kotlinx.html.” When a tab needs to be dynamic, Hotwire (Turbo and Stimulus) gives you SPA-feel navigation and partial updates without you shipping a single line of application JavaScript.
The CSS story has a development/production seam worth knowing. Locally, misk-tailwind uses the Tailwind Play CDN so you can iterate without a build step — the HtmlLayout takes a playCdn flag for exactly this. For production, misk-tailwind ships a real Tailwind NPM project under misk-tailwind/config that scans Misk’s Kotlin for class names and emits a minified, tree-shaken stylesheet. The README is blunt that services “will likely need to mimic this setup” — your tabs use Tailwind classes that Misk’s own scan won’t know about, so you generate your own superset CSS. Don’t discover that at deploy time.
Building a tab
A custom tab is two pieces: a WebAction that renders it, and a registration in your dashboard module. Here’s the action — DashboardPageLayout is injected, gives you the navbar and chrome, and you fill in the body:
@Singleton
class AlphaIndexAction @Inject constructor(
private val dashboardPageLayout: DashboardPageLayout,
) : WebAction {
@Get(PATH)
@ResponseContentType(MediaTypes.TEXT_HTML)
@AdminDashboardAccess
fun get(): String =
dashboardPageLayout.newBuilder().build { _, _, _ ->
div("center container p-8") {
h1("text-base font-bold leading-7 text-gray-900") { +"Alpha Example Admin Tool" }
// ...Tailwind-styled kotlinx.html for the rest of the page
}
}
companion object {
const val PATH = "/_admin/exemplar/alpha/"
}
}
Note @AdminDashboardAccess on the handler. That’s the gate, and it’s the payoff from Part 9: Authentication & Access Control. AdminDashboardAccess is a custom access annotation mapped to capabilities once via AccessAnnotationEntry (capabilities = listOf("admin_console")), so a caller without admin_console gets a 403 before your render code runs. The access check is the same AccessInterceptor that guards every other action — the dashboard isn’t special-cased, it’s just web actions with an access annotation.
Then register it in your module so the navbar knows the tab exists and where it lives:
install(WebActionModule.create<AlphaIndexAction>())
install(
DashboardModule.createHotwireTab<AdminDashboard, AdminDashboardAccess>(
slug = "exemplar-alpha",
urlPathPrefix = AlphaIndexAction.PATH,
menuLabel = "Alpha",
menuCategory = "Admin Tools",
)
)
createHotwireTab<DA, AA> takes two type parameters — the dashboard annotation (AdminDashboard) and the access annotation (AdminDashboardAccess) — and produces a DashboardModule you install. Behind the scenes it multibinds a DashboardTab (slug, URL prefix, menu label and category, required capabilities) and wires the Hotwire tab loader. The slug must match the tab namespace; the menuCategory controls grouping in the navbar. Want a support tab instead? Same call with <SupportDashboard, SupportDashboardAccess>. Want a plain link out to another tool? DashboardModule.createMenuLink<...>. The whole exemplar dashboard — admin tab, support tab, menu links — is assembled this way in ExemplarDashboardModule.kt, the canonical example to read.
DashboardModule also lets you decorate the admin index page itself, via addIndexBlocks and addIndexAccessBlocks — small kotlinx.html fragments the exemplar uses to render an “Authenticated Access” panel that tells the current caller which tabs their capabilities unlock. It’s a nice touch: the dashboard explains its own access model to whoever’s looking at it.
The design choice: server-rendered Hotwire over a React SPA
Misk made a bet, and reversed an earlier one. The v1 dashboard was misk-web: a React framework, separate Node toolchain, separate build, tabs shipped as compiled JS bundles. It worked, and it was a lot of machinery for “let an engineer see a config value.” The v2 stack throws that out: render HTML on the server in the language you already write the service in, sprinkle Hotwire where you need interactivity, style with Tailwind utility classes.
My honest take: for internal operator tooling, this is the right call. The audience is small, the interactions are mostly forms and tables, and the cost of a React SPA (a second toolchain, a second deploy artifact, a second place for dependencies to rot) buys you almost nothing here. A tab is a function returning a string; you can read it top to bottom, the compiler checks your tags, and there’s no API-contract drift between a backend and a frontend because there is no separate frontend. Hotwire covers the genuinely dynamic cases without dragging in a virtual DOM. If you were building a customer-facing app with rich, stateful UI, I’d push back — that’s not what Hotwire-plus-server-rendering is best at. For an admin console, it’s a clean fit and a smaller blast radius.
Production notes & gotchas
- misk-web (the React dashboard) is deprecated — don’t start new tabs there. It still exists, and
createMiskWebTab/createIFrameTabare still inDashboardModulefor legacy and embedded cases, but new work should be Hotwire tabs. Building fresh React tabs in 2026 is signing up to maintain a dead toolchain. - You must generate Tailwind CSS for production. The Play CDN is dev-only convenience. Your tabs use classes Misk’s own scan doesn’t see, so mirror the
misk-tailwind/configNPM project and emit your own tree-shaken stylesheet — or your dashboard renders unstyled in production. - The
slugis load-bearing and must be unique. It namespaces the tab and must line up with the registration; collisions and typos give you a tab that 404s or doesn’t appear. The exemplar deliberately keeps “not found” tabs around to test exactly this failure mode. AdminDashboardModule(isDevelopment = true)and config leak settings are real switches. The exemplar runs withConfigTabMode.UNSAFE_LEAK_MISK_SECRETSso the config tab shows decrypted secrets: wonderful locally, a disaster in production. Default isSAFE; setisDevelopmentand the config mode per environment, deliberately.- Gate every tab with a custom access annotation, not raw capabilities.
@AdminDashboardAccessand@SupportDashboardAccessmap to capabilities in one place. Inline capability strings scattered across tabs are how a dashboard endpoint ends up quietly mis-secured. - Don’t lean on the fake authenticator’s defaults. In dev you’re “logged in” as a caller with
admin_console,customer_support, and friends, so every tab is visible. That’s not production. Confirm the real capabilities map to the right people before you assume your gating works.
What’s next — the end of the series
This is Part 24, and there is no Part 25. So instead of a teaser, a reckoning with the whole arc.
We started in Part 1 asking what Misk even is — an opinionated Kotlin service container from Cash App — and spent the next twenty-three posts answering it by building one. The foundations came first: Guice dependency injection and the service lifecycle that starts and stops everything in dependency order. Then the web layer — web actions, JSON and multipart marshalling, interceptors, and gRPC. We locked it down with authentication and access control and OPA-based authorization, then handled crypto and secrets.
Persistence got its own run: JDBC data sources, the Hibernate transacter, schema migrations, and the SQLDelight/jOOQ/Vitess options. We made services fast and safe with Redis caching and rate limiting, then went async and distributed with job queues, cron and scheduled jobs, and distributed leases. We made it operable, with configuration and observability, and provably correct with testing. And here, the dashboard: the internal surface you’ll actually live in once it’s running.
That’s a production service. Not a toy — DI, lifecycle, a hardened web layer, real persistence, distributed coordination, metrics and traces, a test strategy, and an operator console. If you’ve followed along, you can build one and run it.
There’s one more road, taken as a bonus: exposing a Misk service to AI agents over the Model Context Protocol, via the misk-mcp module. If you want your service to be a tool an LLM can call, read the encore — Bonus: Exposing Your Misk Service to AI with misk-mcp. For now, you have the whole container. Go ship something.
Target keywords: misk admin dashboard, misk hotwire tailwind.
Comments