render json: a hash from a controller action. That's a dashboard widget.
The query is one you've probably already written in a scope. This is a route that returns it in a shape something else knows how to draw.
The reality
Ruby has good answers here, and they're worth naming honestly before arguing with them.
Chartkick and Groupdate are genuinely delightful. line_chart User.group_by_day(:created_at).count is hard to beat for effort-to-result. The catch is that it renders inside your app: it needs a view, the asset pipeline, and a page behind your authentication. Fine for an internal admin page. Awkward when someone wants it on a TV in the office, or on their phone, or shared with a co-founder who has no account.
Blazer is excellent if the job is running SQL and sharing the results. It's a query tool with charts, not a live board.
ActiveAdmin and Administrate are CRUD surfaces with dashboard sections attached. If you need the CRUD, take them.
The gap is a board that is always current, readable outside the app, and doesn't require mounting an engine in routes.rb to display six numbers.
Your data stays in your Rails application
dashboardbase never asks for database credentials and never stores your data. It calls your endpoint over HTTPS and renders the response.
Your database.yml, your ActiveRecord connection, your Rails credentials, your third-party API keys — all of it stays on your servers. There is nothing on our side that could query your database, because nothing on our side knows how.
Your existing scopes still apply. If current_tenant already narrows every query in the app, it narrows this one too — no permission model to rebuild inside a reporting tool.
Define Rails endpoints
A progress list is a good fit for anything measured against a cap — quota usage, quarterly goals, storage by service:
{
"title": "Storage",
"actions": [
{
"title": "Manage",
"type": "link",
"url": "https://example.com/storage"
}
],
"data": {
"header": {
"title": "Storage by service",
"subtitle": "Last synced 5m ago"
},
"items": [
{ "value": 820, "max": 1000, "label": "Postgres" },
{ "value": 340, "max": 500, "label": "Blob" },
{ "value": 470, "max": 500, "label": "Redis" }
]
}
}{
"title": "Storage",
"actions": [
{
"title": "Manage",
"type": "link",
"url": "https://example.com/storage"
}
],
"data": {
"header": {
"title": "Storage by service",
"subtitle": "Last synced 5m ago"
},
"items": [
{ "value": 820, "max": 1000, "label": "Postgres" },
{ "value": 340, "max": 500, "label": "Blob" },
{ "value": 470, "max": 500, "label": "Redis" }
]
}
}{
"title": "Storage",
"actions": [
{
"title": "Manage",
"type": "link",
"url": "https://example.com/storage"
}
],
"data": {
"header": {
"title": "Storage by service",
"subtitle": "Last synced 5m ago"
},
"items": [
{ "value": 820, "max": 1000, "label": "Postgres" },
{ "value": 340, "max": 500, "label": "Blob" },
{ "value": 470, "max": 500, "label": "Redis" }
]
}
}A controller and a before_action:
# app/controllers/dashboard_controller.rb
class DashboardController < ActionController::API
before_action :verify_dashboardbase_secret
def storage
render json: {
title: 'Storage',
actions: [
{ title: 'Manage', type: 'link', url: 'https://example.com/storage' }
],
data: {
header: {
title: 'Storage by service',
subtitle: "Last synced #{time_ago_in_words(StorageReport.last.created_at)} ago"
},
items: StorageReport.current.map do |report|
{ value: report.used_mb, max: report.quota_mb, label: report.service }
end
}
}
end
private
def verify_dashboardbase_secret
provided = request.headers['x-dashboardbase-secret'].to_s
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, SECRET)
end
end# app/controllers/dashboard_controller.rb
class DashboardController < ActionController::API
before_action :verify_dashboardbase_secret
def storage
render json: {
title: 'Storage',
actions: [
{ title: 'Manage', type: 'link', url: 'https://example.com/storage' }
],
data: {
header: {
title: 'Storage by service',
subtitle: "Last synced #{time_ago_in_words(StorageReport.last.created_at)} ago"
},
items: StorageReport.current.map do |report|
{ value: report.used_mb, max: report.quota_mb, label: report.service }
end
}
}
end
private
def verify_dashboardbase_secret
provided = request.headers['x-dashboardbase-secret'].to_s
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, SECRET)
end
end# app/controllers/dashboard_controller.rb
class DashboardController < ActionController::API
before_action :verify_dashboardbase_secret
def storage
render json: {
title: 'Storage',
actions: [
{ title: 'Manage', type: 'link', url: 'https://example.com/storage' }
],
data: {
header: {
title: 'Storage by service',
subtitle: "Last synced #{time_ago_in_words(StorageReport.last.created_at)} ago"
},
items: StorageReport.current.map do |report|
{ value: report.used_mb, max: report.quota_mb, label: report.service }
end
}
}
end
private
def verify_dashboardbase_secret
provided = request.headers['x-dashboardbase-secret'].to_s
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, SECRET)
end
endWith the secret read once at boot, so a misconfigured deploy fails loudly instead of quietly serving your numbers to anyone:
# config/initializers/dashboardbase.rb
SECRET = ENV.fetch('DASHBOARDBASE_ENDPOINT_SECRET') do
raise 'DASHBOARDBASE_ENDPOINT_SECRET is not set'
end# config/initializers/dashboardbase.rb
SECRET = ENV.fetch('DASHBOARDBASE_ENDPOINT_SECRET') do
raise 'DASHBOARDBASE_ENDPOINT_SECRET is not set'
end# config/initializers/dashboardbase.rb
SECRET = ENV.fetch('DASHBOARDBASE_ENDPOINT_SECRET') do
raise 'DASHBOARDBASE_ENDPOINT_SECRET is not set'
endThat header is the workspace endpoint secret. Every workspace has one and dashboardbase sends it on every request to your endpoints, automatically — nothing to wire up 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.
Sinatra is the same hash:
get '/dashboard/storage' do
halt 401 unless ActiveSupport::SecurityUtils.secure_compare(
request.env['HTTP_X_DASHBOARDBASE_SECRET'].to_s, SECRET
)
json title: 'Storage', data: { items: storage_items }
endget '/dashboard/storage' do
halt 401 unless ActiveSupport::SecurityUtils.secure_compare(
request.env['HTTP_X_DASHBOARDBASE_SECRET'].to_s, SECRET
)
json title: 'Storage', data: { items: storage_items }
endget '/dashboard/storage' do
halt 401 unless ActiveSupport::SecurityUtils.secure_compare(
request.env['HTTP_X_DASHBOARDBASE_SECRET'].to_s, SECRET
)
json title: 'Storage', data: { items: storage_items }
endCharts follow the same envelope with labels and datasets inside data, where each point is a hash with a value key.
Generate Ruby 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 controller actions render the right hash rather than a plausible one.
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. There's more on where that helps and where it doesn't in building a dashboard with AI.
Then paste a response into the endpoint validator to confirm the widget renders it — no account required.
Where this isn't the right fit
You need CRUD. ActiveAdmin and Administrate exist for a reason. This is read-only by design.
Charts inside your own views. If the chart belongs on a page in your app, Chartkick is a better answer and a smaller one.
Ad-hoc SQL. Blazer is the right tool for running a query and sharing the result.
The dashboard is a customer-facing feature. Build it into the app.
Add one action and see
Add the controller action above to an app 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, and shares as a link without handing anyone a login.
Worth reading next: if your API returns structured JSON, you're one step away from a dashboard for the mental model behind this.