Skip to content
OTFotf
All posts

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

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

A financial dashboard template is not production-ready because its charts look polished. The useful test starts when a user changes the date range, opens page four, exports a filtered report, loses access, or retries after a timeout. Those are not edge details around the product. They are the product.

A good template gives you a clear starting point for the interface and a set of patterns you can extend. It should not hide the work required for authorization, data volume, background processing, and recovery. Before choosing one, inspect how it handles the second click—not just the first screenshot.

Start with the data path

A dashboard screen usually combines a summary, a table, filters, and one or more actions. Those surfaces should share a defined query model. If the chart uses one date range while the table uses another, the user cannot tell which number to trust.

Define the input once:

type ReportQuery = {
  range: "7d" | "30d" | "90d" | "custom"
  from?: string
  to?: string
  accountId?: string
  status?: "all" | "open" | "paid" | "failed"
  page: number
  pageSize: number
}

Validate the range, page, page size, account, and status on the server. The UI can offer friendly controls, but the server decides whether the values are allowed and whether the actor may access the selected account.

Keep the query model separate from the visual components. A chart should consume a report result; it should not know how to read the database. A table should consume the same query constraints as the export job. This makes it possible to test the data contract without rendering the entire screen.

Put filters in the URL when they describe the view

A date range, account, status, and page number usually describe a shareable view. Store those values in the URL rather than only in component state. A refresh should preserve the user’s place. The back button should return to the prior report. A copied link should reopen the same view.

The browser’s URLSearchParams API provides the standard operations for reading, setting, appending, and deleting query parameters. Keep the values short and canonical:

function parseReportQuery(url: URL): ReportQuery {
  const params = url.searchParams
  const page = Math.max(1, Number(params.get("page") ?? "1"))
  const pageSize = Math.min(100, Math.max(10, Number(params.get("size") ?? "25")))

  return {
    range: parseRange(params.get("range")),
    accountId: params.get("account") ?? undefined,
    status: parseStatus(params.get("status")),
    page,
    pageSize,
  }
}

Do not trust query parameters just because they came from your own UI. Validate them, apply sensible limits, and check resource ownership before querying. The URL is a source of view state, not an authorization token.

When a filter changes, reset the page to one. Otherwise a user can select a narrow range while remaining on a page that no longer exists. The table, chart, summary, and export action should all receive the same normalized query.

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

Paginate before the database becomes the interface

A demo can fetch every transaction into the browser. A production dashboard should define a result limit, return a page of data, and expose the total or next-page information the UI needs.

type Page<T> = {
  items: T[]
  page: number
  pageSize: number
  hasNext: boolean
}

async function listTransactions(
  actor: Actor,
  query: ReportQuery,
): Promise<Page<Transaction>> {
  await authorizeReportRead(actor, query.accountId)

  const items = await db.transaction.findMany({
    where: buildTransactionWhere(query),
    orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
    take: query.pageSize + 1,
    skip: (query.page - 1) * query.pageSize,
  })

  return {
    items: items.slice(0, query.pageSize),
    page: query.page,
    pageSize: query.pageSize,
    hasNext: items.length > query.pageSize,
  }
}

Use a stable ordering. If rows arrive with equal timestamps and the query has no second ordering key, items can move between pages while the user navigates. A cursor can be a better fit for very large or frequently changing result sets; choose it based on the data access pattern rather than copying a pagination widget.

Show the current range, page, and result state near the table. Preserve the selected filters when moving between pages. A loading indicator should not erase the previous useful result before the next result is ready.

Treat loading, empty, and error as product states

Every dashboard query has more states than “data” and “no data.” Define the behavior for loading, empty, partial, failed, and stale results.

function ReportTable({ result, retry }: Props) {
  if (result.status === "loading") {
    return <TableSkeleton rows={8} />
  }

  if (result.status === "error") {
    return <ErrorState title="Report unavailable" onRetry={retry} />
  }

  if (result.items.length === 0) {
    return (
      <EmptyState
        title="No transactions in this range"
        hint="Try a wider date range or check the import status."
      />
    )
  }

  return <DataTable rows={result.items} />
}

An empty result is not necessarily an error. Tell the user whether there is no data, the import has not finished, the selected account has no records, or the actor lacks access. Do not turn a permission failure into an empty table; that makes security behavior indistinguishable from missing data.

A retry button should be safe. If the previous request may still be running, avoid starting duplicate work without a request policy. For a query, cancellation or request deduplication may be enough. For a side effect, use an operation identifier.

Make export a job, not a giant request

Exports often outlive the browser request that started them. Generate the export in a background job, store its status, and return a job identifier. The dashboard can poll or subscribe to status and present a download when the file is ready.

type ExportStatus =
  | { state: "queued"; jobId: string }
  | { state: "running"; jobId: string; progress?: number }
  | { state: "ready"; jobId: string; downloadUrl: string }
  | { state: "failed"; jobId: string; message: string }

The job must receive the normalized query, not just the visible page. If the user filtered by account, status, and date range, the export should use those same constraints. Store who requested it and which authorization scope was checked.

Make the download URL short-lived and scoped to the requesting actor. Do not place raw database queries, secrets, or unbounded filters in a downloadable URL. Expire the file according to your retention policy and remove it when it is no longer needed.

If the user clicks Export twice, decide whether that creates two jobs or returns the existing one. Make the choice explicit. A stable operation ID is often the simpler behavior for a report export.

Enforce access at the data boundary

Hiding an export button does not protect an export endpoint. A user can call the endpoint directly, replay a request, or change an account identifier in the URL. Authorization must run on the server for every read and write.

export async function getReport(request: Request) {
  const actor = await requireActor(request)
  const query = parseReportQuery(new URL(request.url))

  await authorizeReportRead(actor, query.accountId)

  const page = await listTransactions(actor, query)
  return Response.json(page)
}

Check both the actor and the resource scope. If reports belong to a workspace, derive the workspace from the authenticated session and ensure the requested account belongs to it. Do not accept a workspace ID as proof that the actor belongs to that workspace.

Record denied attempts in an audit trail when the report contains sensitive business information. The record should include the actor, resource scope, action, decision, and request identifier without copying unnecessary customer data.

For the same principle in an AI-enabled application, see safe AI agent tool permissions.

Retry external operations safely

Dashboards frequently connect to payment, accounting, import, or notification systems. A timeout does not tell you whether the external operation happened. Retrying without an idempotency key can create duplicate customers, charges, jobs, or records.

Stripe’s idempotent requests documentation explains the pattern for safely retrying an operation with an idempotency key and returning the same result for the same key. Apply the same thinking to your own job and report APIs: generate a stable key for the logical operation, persist the result, and reject a reuse with different parameters.

async function startExport(input: ExportInput) {
  const key = input.operationId
  const existing = await db.exports.findUnique({ where: { key } })
  if (existing) return existing

  const job = await db.exports.create({
    data: {
      key,
      actorId: input.actorId,
      query: input.query,
      state: "queued",
    },
  })

  await queue.publish({ jobId: job.id })
  return job
}

The database constraint on key matters. An application-level “check then insert” can race when two requests arrive together. Test a timeout, a worker restart, a duplicate request, and a retry with changed parameters.

Keep charts honest

A chart is a view of a query, not a separate source of truth. Use the same normalized date range, account scope, and status filters for the chart and table. Label the period and timezone. Explain whether a value is a total, average, rate, or snapshot.

If the chart uses a sampled or delayed data set, say so. If the table contains only settled transactions while the summary includes pending records, make the difference visible. A visually consistent dashboard can still mislead if its definitions are inconsistent.

Format currency and dates according to the user’s context, but keep the underlying values precise. Avoid rounding before aggregation. Test zero, negative, very large, and missing values. A financial dashboard needs predictable behavior for corrections and refunds, not just positive amounts.

Use a template as a starting boundary

When evaluating a financial dashboard template, inspect the code behind the screen. Look for server-side permission checks, stable query boundaries, URL-backed filters, background export handling, retry behavior, and tests for empty and denied states.

The current OTF templates page verifies that OTF offers a free MIT component SDK and a free AI configurations pack for Cursor, Claude, and Lovable. Do not infer that a template includes a particular billing or reporting integration unless its current product page documents it. Treat the template as a starting point and verify the actual kit scope before committing.

A useful extension task for an AI coding agent is deliberately specific:

Add a “Refunds by reason” report.

- Reuse the existing report query and permission helper.
- Store range, account, and page in the URL.
- Add loading, empty, error, and success states.
- Queue exports; do not generate them in the request.
- Add a duplicate-request test and a cross-account denial test.
- Report checks run and checks not run.

That brief points the agent toward existing boundaries instead of asking it to regenerate a page from a screenshot. Production repository conventions for AI coding agents covers the repository rules that make this kind of extension safer.

A financial dashboard template earns its place when it handles the second click: a narrow filter, an empty result, a denied account, a slow export, and a retry after a timeout. Charts are part of the interface, but pagination, query state, authorization, background work, and recovery are what let a team run the dashboard with real data. Build or choose those boundaries first, then make the screen beautiful around them.

Sources

templatesbackendarchitecture
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