Skip to content
OTFotf
All posts

Beyond Pretty Charts: The Essential Features of a Real Financial Dashboard

D
DaveAuthor
7 min read
Beyond Pretty Charts: The Essential Features of a Real Financial Dashboard

A pricing page screenshot tells you nothing. The difference between a dashboard template and a dashboard you'd actually run your business on lives in the boring parts — the eight edge cases that surface the first time a real user clicks a real button on a real Tuesday.

Here's the checklist I run every dashboard candidate through, what most templates ship instead, and what the SaaS Dashboard kit at saas.otf-kit.dev already has wired. The point isn't that templates are bad. The point is that pretty and wired are two different products, and the wiring is what compounds.

marketing screenshots of charts vs the eight edge cases the user actually hits

1. Server pagination on every table

Pretty templates ship a table that fetches everything. That's fine for the demo with 47 rows. It's a wall on day one of production, where transactions are 40M rows and the user clicked "page 2."

Server pagination is three decisions made correctly:

// page state lives in the URL, not component state
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
const [pageSize, setPageSize] = useQueryState('size', parseAsInteger.withDefault(25))

const { data, isLoading, error } = useSWR(
  ['transactions', page, pageSize, range],
  ([, p, s, r]) => fetchTransactions({ page: p, size: s, range: r })
)

The URL is the source of truth so a refresh doesn't lose the user's place, the browser back button works, and a shared link deep-links to "March, page 4, 25 per page." That last one is the test most templates fail.

2. The three states every async surface owes you

Loading, error, empty. One component, three branches. Every screen, every table, every card.

function TransactionsTable({ range }: { range: DateRange }) {
  const { data, error, isLoading, refetch } = useTransactions(range)

  if (isLoading) return <TableSkeleton rows={10} />
  if (error)     return <ErrorState error={error} onRetry={refetch} />
  if (!data?.length) return (
    <EmptyState
      title="No transactions in this range"
      hint="Try widening the date filter or check the import job."
      action={<Button onClick={openImportGuide}>How to import</Button>}
    />
  )

  return <DataTable columns={columns} rows={data} />
}

The empty state is the one everyone forgets. It's also the one that tells you the template author actually thought about the user. A bare "No data" is a confession. A hint plus a CTA is a product.

11 production screens. Login, database, payments — all wired.

The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.

See the live demo

3. Date-range filters that compose

A date picker bolted onto a table is a demo. A date picker that composes with pagination, with the URL, with the export button, and with whatever filter the user adds next is a system.

The composition rule: the filter is a URL param, the table reads from URL, the export reads from URL, the chart reads from URL. One source of truth, four surfaces.

// filters live in the URL; everything reads them
const [range, setRange]     = useQueryState('range',  parseAsString.withDefault('last_30d'))
const [account, setAccount] = useQueryState('account', parseAsString.withDefault('all'))
const [status, setStatus]   = useQueryState('status',  parseAsString.withDefault('all'))

When the filter and the table share state via the URL, the export button automatically exports the filtered view. When the agent regenerates the page, the filter survives because it was never in component state to begin with.

4. Exports that survive a slow database

Exports are where dashboards go to die. The user clicks "Export CSV," the request times out at 30 seconds, the browser shows nothing, the user clicks again, and now the job is queued twice.

async function onExport(range: DateRange) {
  const job = await startExportJob({ range })       // returns a jobId
  toast.promise(pollExport(job.id), {
    loading: 'Preparing your export…',
    success: (url) => { window.location.href = url; return 'Download ready' },
    error: (e) => `Export failed: ${e.message}`,
  })
}

Job-based, polled, with a real toast that survives a page refresh. The CSV isn't generated in the request — it's generated by a worker against the same query the table uses, so pagination and filters apply identically. If your template ships exports as a synchronous Blob download, it has never met a real database.

5. Access control at the data layer, not the button

Hiding a button is not access control. A user with reports.read: false who guesses the URL still sees the report. Access control has to fail closed at the data layer.

// server route guards the query, not the UI
export async function GET(req: Request) {
  const user = await requireUser(req)
  if (!user.can('reports.read')) return new Response('forbidden', { status: 403 })

  const rows = await db.transaction.findMany({
    where: { orgId: user.orgId, ...dateFilter(req) },
  })
  return Response.json(rows)
}

The UI hides the link. The server denies the query. The audit log records the attempt either way. This is the boring layer that makes the dashboard safe to hand to a paying customer on day one — and it's the layer most templates leave entirely to you.

6. Walking the live SaaS Dashboard kit

saas.otf-kit.dev is the live demo, not a screenshot of one. Auth and Stripe are wired — not stubbed — so the demo runs real sessions and real webhooks. The kit ships full-stack: auth, billing, DB, and Stripe already connected. You clone it, point your keys, and the dashboard is live.

What's wired out of the box:

  • Transactions table with server pagination, URL-backed filters, and the three states from section 2 — the empty state isn't "No data," it's the actual hint-with-CTA.
  • MRR / churn charts reading from the same query layer as the tables, so the chart and the table never disagree.
  • Customer list with role-based columns hidden at the server, not the client. A user without customers.export cannot export customers, full stop.
  • Export job with a polled download URL and a toast that survives the navigation that triggers the download.
  • Settings → Billing running the actual Stripe customer portal, not a mock.

The 24-item design checklist runs as a script before the kit ships, so spacing, focus rings, and dark mode aren't aspirational — they're enforced. The same component primitive renders in dark mode without a second stylesheet because the design tokens flip one theme across the whole surface.

7. Adding a new metric screen via the kit's prompts

The kit ships a tested prompt library — CLAUDE.md plus .cursorrules plus a folder of prompts the team has run end-to-end against this codebase. The point isn't to write a prompt. The point is that the prompt has already been run against this kit, on this stack, and the result is in the repo.

A new "Refunds by reason" metric screen:

# ai/prompts/06-new-metric-screen.md

Add a new metric screen to the SaaS Dashboard.

Inputs:
- New metric: refunds grouped by reason
- Where: under `/dashboard/reports`, visible to users with `reports.read`
- Visualisation: bar chart + a top-10 table
- Date range filter must compose with the existing URL state

Steps:
1. Add the query in `server/reports/refunds.ts` using the existing
   `dateFilter` helper and `requirePermission('reports.read')`.
2. Add the route in `app/dashboard/reports/refunds/page.tsx`.
3. Use `DataTable` for the table, `<Card>` for the chart, and the
   existing `DateRangePicker` — do not introduce new components.
4. Wire the export button to `startExportJob`.
5. Run `pnpm kit:check` (the 24-item script). All checks must pass.

Point Claude Code or Cursor at the prompt. The agent extends the kit instead of regenerating it, because CLAUDE.md already tells it the conventions — what the existing helpers are, where queries live, what the design tokens are. A prompt without CLAUDE.md is a coin flip; a prompt with it is a refactor.

the agent extends the kit instead of regenerating it — CLAUDE.md is the contract

The new screen inherits pagination, filter composition, access control, and the three states for free, because those live in the kit, not in the screen. That is the entire bet: a wired layer underneath the screens, so every new screen ships the boring parts on day one.

What this gets you

A new metric in fifteen minutes, not a new metric after a sprint of "we should also add empty states." The eight-item checklist stops being a backlog and starts being the floor — every screen inherits it because the kit enforces it. That's the difference between a dashboard you demo and a dashboard you run a business on.

A template can win on screenshots for one launch cycle. It loses the moment the user paginates, filters, exports, or hits the empty state. Build for the second click, not the first. The kit is at saas.otf-kit.dev — clone it, point your keys, and the boring parts are already done.

the clay character at a laptop, screens behind it showing a new metric live in the dashboa

design-systemtemplatesbackend
OTF SaaS Dashboard Kit

Ship the product, not the setup.

  • 11 production screens — auth, billing, team, analytics, settings
  • Real database, payments, and login — all wired on day 1
  • AI configs pre-tuned so your agent extends instead of regenerates