What Are Dashboards Used For? Four Jobs, Audited in a Shipped One
Monitoring, stating what a user has, offering actions, and auditing them. Which jobs a real 505-line dashboard does, and the two defects it took to get right.
Dashboards are used for four jobs: monitoring a number that moves, stating what the user has, offering the actions that state allows, and leaving an audit trail of what was done. Analytics dashboards do the first. Most screens shipped as dashboards — including the one in this codebase — exist mainly for the middle two.
That distinction is not pedantry. It decides what you build. A monitoring dashboard needs charts, time ranges and a refresh story. An account dashboard needs a correct answer to "what do I own", and the charts are decoration. This post audits the four jobs against src/app/dashboard/page.tsx in the ASoc storefront — a 505-line Server Component that is the only dashboard we actually run in production code — and names which jobs it does, which it skips, and the two defects we hit getting the middle two right.
The four jobs, and which ones a real dashboard does
| Job | What it answers | Does /dashboard do it? | Where it lives |
|---|---|---|---|
| Monitor | "Is the number healthy right now?" | No — nothing here moves on its own | — |
| State | "What do I have access to?" | Yes, this is the whole point | src/lib/dashboardDownloads.ts |
| Act | "What can I do about it?" | Yes — redeem, download, request a refund | src/lib/actions/{redemption,refund,account}.ts |
| Account | "What happened, and who did it?" | Yes, server-side | download_events table |
Three of four, and the missing one is the one most people picture when they hear the word. Worth being honest about that before you spend a sprint on charts.
Job 1: monitoring is a narrower use case than it looks
A monitoring dashboard earns its keep when a human decision depends on a number that changes faster than a report cycle: queue depth, error rate, revenue today, inventory. The telling test is whether anyone would open the page twice in a day. If not, you want a report, an email, or an alert — not a screen somebody has to remember to visit.
The ASoc dashboard fails that test deliberately. A buyer's entitlements change when they buy something, roughly never otherwise. So there is no polling, no refresh interval, and no chart. DashboardTabs renders all three tabs' content server-side up front and only toggles visibility:
export type DashboardTab = "overview" | "purchases" | "settings";
const TABS: { id: DashboardTab; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "purchases", label: "Purchases & Downloads" },
{ id: "settings", label: "Settings" },
];
Switching tabs never re-fetches and never shows a loading state, because the data was already correct when the page rendered. That is only a defensible design because nothing on the page is live. If you are building the monitoring kind, this is exactly the shortcut you cannot take — and the layout tradeoffs for the chart-heavy kind are worked through in building a data dashboard.
Job 2: stating what the user has is where dashboards actually get hard
This is the job that generates bugs, because the answer is derived rather than stored. A buyer does not own "a row". They hold entitlement slots from one or more orders, and each slot covers some set of products and editions. The whole decision is 75 lines in src/lib/entitlements.ts, and it is a pure function:
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
if (slot.status !== "active") return false;
switch (slot.kind) {
case "all_access":
return true;
case "all_templates":
return target.framework !== "backend";
case "template_single":
return (
slot.productSlug === target.productSlug &&
target.framework !== "backend"
);
}
}
Pure on purpose: the dashboard and the download endpoint must agree about ownership, and the cheapest way to guarantee that is for both to call the same function rather than each writing its own query. computeAggregatedDownloads in src/lib/dashboardDownloads.ts walks every active slot the caller holds and produces a deduplicated per-product list — using authorizeDownload, the same decision /api/download makes, so a row only appears if the download would actually succeed.
The defect that taught us this. An earlier version computed downloads per order, inside dashboard/page.tsx. A buyer who owned one template and later bought All-Access saw that template twice, once under each order, with two Download buttons that did the same thing. Nothing was insecure; the page was simply lying about the shape of what they owned. The fix was to aggregate across all orders in one place, which is why the function lives in src/lib/ and not in the page.
That is the general lesson for job 2: a dashboard that renders records will show the user your database schema. A dashboard that renders derived state shows them their situation. Users only ever asked for the second.
Job 3: the actions are the dashboard, not a feature of it
Every row on this page exists to enable exactly one action. Nothing is there to be looked at:
- An unredeemed
template_singleslot rendersRedemptionPicker— pick which template the slot becomes. - A product you own renders a
DownloadMenuper ready edition, resolved throughsrc/lib/downloadOptions.ts. - A recent order renders
RefundButton, gated bysrc/lib/refundEligibility.ts(62 lines, entirely about whether the window is open). - An unverified email renders a "resend verification" button, via
resendVerificationEmailinsrc/lib/actions/account.ts.
Writing it out as a list is a good audit. If a block on your dashboard cannot be described as "this exists so the user can do X", it is probably there because it was easy to query. The relationship between account state and what the UI offers is the same problem as role-based access control in an admin dashboard, one tier down.
Two rules we would keep on any account dashboard:
- The dashboard's ownership check is a convenience, never the gate.
src/lib/useOwnedProducts.tsmemoizes one lookup per page load so cards can show an owner-only Download button, but/api/downloadre-runsauthorizeDownloadper request against server-held slots. The UI can be wrong; the endpoint cannot. - Never trust a request parameter for identity.
getCheckoutUrltakes a tier and nothing else — the buyer's id and email come from the verified session, so a caller cannot attach a checkout to someone else's account.
Job 4: the audit trail nobody sees
The fourth job is invisible to the user and the reason a dashboard can be trusted at all. Every issued download writes a row to download_events with the product, edition, version, first-hop IP and user agent. Two decisions in there are worth copying.
The audit survives account deletion. supabase/migrations/0005_retain_anonymized_download_audit.sql changed the foreign key from cascade-delete to on delete set null, so deleting an account anonymizes the trail instead of erasing it. An abuse record that a self-serve delete button can wipe is not a record.
The counting had a race. The hourly download limit was enforced as a check-then-act: read the count, then insert the audit row in a separate call. Under concurrency, N parallel requests all read the same count, all passed the < limit check, and all inserted — a burst walked straight past the cap. That is a textbook TOCTOU bug (CWE-367), and the fix in supabase/migrations/0007_atomic_download_rate_limit.sql does the count and the conditional insert in one SECURITY DEFINER RPC, serialized per user by a transaction-scoped advisory lock:
create or replace function public.record_download_within_limit(
p_user_id uuid, p_product_slug text, p_framework text,
p_version text, p_ip inet, p_user_agent text, p_limit int
) returns int
language plpgsql security definer set search_path = public, pg_temp
It returns the post-insert count, or -1 when over the limit — and in that case inserts nothing, because a rejected download is not a download. If your dashboard shows a usage number next to a quota, that number is almost certainly being read and written non-atomically somewhere. Go and look.
What dashboards are used for versus what they cost
| Dashboard type | Primary job | Refresh model | What it needs most |
|---|---|---|---|
| Analytics / BI | Monitor | Scheduled or live | Query performance, time ranges, chart literacy |
| Operational / admin | Monitor + act | Live | Permissions, audit trail, safe destructive actions |
| Customer account | State + act | On navigation | A correct ownership model |
| Internal reporting | Monitor | Daily or weekly | Honest definitions of each metric |
The cost of the wrong pick is concrete. Our dashboard's client stack leaked onto pages that have no account UI at all: Header and useOwnedProducts both imported @/lib/supabase/client at module scope, so @supabase/ssr plus auth-js — 68 KiB gzipped, 255 KiB parsed — sat in the initial bundle of /, /blog, /docs and /pricing and had to be parsed before those pages could settle. src/lib/supabase/lazyClient.ts now imports it dynamically and skips it entirely when no sb-*-auth-token cookie is present. Mobile performance on the pages that never needed it recovered immediately; the gates themselves were untouched, because they were never client-side.
The other cost is indexing. A dashboard is per-user, so it is noindex by metadata, not by robots rules:
export const metadata: Metadata = {
title: "Dashboard",
robots: { index: false, follow: false },
};
It is also one of only eight routes in this build that render per request. Everything else is static HTML. A dashboard is the expensive part of a site by construction — which is the argument for keeping it small, and for keeping marketing pages well clear of it.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The same item appears twice on a user's dashboard | State computed per order/record instead of aggregated per user | Derive once from all the user's rows, in one shared function |
| Dashboard says the user owns something, the download 403s | Two different ownership queries | Have both call one pure predicate |
| A usage count drifts above its quota under load | Non-atomic check-then-act | Count and insert in one transaction with a per-user lock |
| Marketing pages got slower after adding an account area | Auth client imported at module scope | Import it dynamically; probe for the session cookie first |
| Dashboard URLs show up in search results | noindex missing on the route | Export robots: { index: false, follow: false } in page metadata |
| Deleting an account erases the abuse trail | Audit FK cascades on user delete | Make the user column nullable with on delete set null |
FAQ
What are dashboards used for in a business context? Chiefly monitoring a metric that changes faster than the reporting cycle, so somebody can act on it the same day. If nobody would open the page twice in one day, the use case is a scheduled report or an alert, not a dashboard.
What is the difference between a dashboard and a report? A report is a snapshot assembled for a reader at a point in time. A dashboard is a live surface the reader visits, with the current state and — in most real ones — the actions that state permits.
Do dashboards need charts? No. Charts serve the monitoring job. A customer account dashboard's job is to state what the user has and let them act on it; on this storefront's dashboard there is not a single chart, and nothing is missing.
Should a dashboard be server-rendered? For an account dashboard, yes — the data is per-user and must be fetched with the session anyway, so rendering it on the server avoids shipping both the query and the auth client to the browser. Prerender the marketing pages instead, where the win is much larger.
Templates with the structure already wired
The pattern above — static marketing pages, with session-bound surfaces kept small and separate — is what every ASoc landing template starts from. Browse the Next.js landing page templates or the Tailwind landing page templates if you would rather extend that baseline than assemble it.
