Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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

JobWhat it answersDoes /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 pointsrc/lib/dashboardDownloads.ts
Act"What can I do about it?"Yes — redeem, download, request a refundsrc/lib/actions/{redemption,refund,account}.ts
Account"What happened, and who did it?"Yes, server-sidedownload_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_single slot renders RedemptionPicker — pick which template the slot becomes.
  • A product you own renders a DownloadMenu per ready edition, resolved through src/lib/downloadOptions.ts.
  • A recent order renders RefundButton, gated by src/lib/refundEligibility.ts (62 lines, entirely about whether the window is open).
  • An unverified email renders a "resend verification" button, via resendVerificationEmail in src/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:

  1. The dashboard's ownership check is a convenience, never the gate. src/lib/useOwnedProducts.ts memoizes one lookup per page load so cards can show an owner-only Download button, but /api/download re-runs authorizeDownload per request against server-held slots. The UI can be wrong; the endpoint cannot.
  2. Never trust a request parameter for identity. getCheckoutUrl takes 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 typePrimary jobRefresh modelWhat it needs most
Analytics / BIMonitorScheduled or liveQuery performance, time ranges, chart literacy
Operational / adminMonitor + actLivePermissions, audit trail, safe destructive actions
Customer accountState + actOn navigationA correct ownership model
Internal reportingMonitorDaily or weeklyHonest 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

SymptomLikely causeFix
The same item appears twice on a user's dashboardState computed per order/record instead of aggregated per userDerive once from all the user's rows, in one shared function
Dashboard says the user owns something, the download 403sTwo different ownership queriesHave both call one pure predicate
A usage count drifts above its quota under loadNon-atomic check-then-actCount and insert in one transaction with a per-user lock
Marketing pages got slower after adding an account areaAuth client imported at module scopeImport it dynamically; probe for the session cookie first
Dashboard URLs show up in search resultsnoindex missing on the routeExport robots: { index: false, follow: false } in page metadata
Deleting an account erases the abuse trailAudit FK cascades on user deleteMake 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.

Keep reading

Tutorial8 min read

What Does Next.js Do? Five Jobs, Counted From a Real Build

Next.js routes, renders, bundles, runs server code and emits metadata. Each job checked against this storefront's build output: 425 HTML files, 8 dynamic routes.

Read more
Tutorial8 min read

What Is Supabase Used For? Four Things, in One Real App

Four things, precisely: auth, row-level authorization, private storage, atomic writes -- including the rate-limit race this app actually hit and fixed.

Read more