Skip to content
SaaS Architecture9 min read·

What a SaaS dashboard needs before you let customers see it

Every page ranking for building a SaaS dashboard is about how it looks: the card grid, the chart library, the Tailwind template. None of them covers what makes a dashboard trustworthy: which numbers are scoped to the tenant, how stale each one is allowed to be, what a brand new account sees, and the permission check behind every widget. The list I work through before launch, with the code and the tests.

SaaSDashboardsMulti-TenancyAuthorisationCachingProduct Engineering

A customer's operations lead opens your dashboard on Monday morning and sees 1,204 active users. She exports the user list to reconcile it against their HR system and the CSV has 1,187 rows. She sends both screenshots to her CEO, and her CEO sends them to yours with one line: which of these is true?

Both are, sort of. The dashboard counted anyone who logged in during the last 30 days in UTC, from a rollup refreshed overnight. The export counted users who are active right now, in the customer's timezone, minus seventeen deactivated on Friday. Nothing is broken. But the dashboard has just told a paying customer something your own product contradicts, and from that point every number on it is a number they check.

Search for how to build a SaaS dashboard and page one is template sellers, Tailwind tutorials and YouTube walkthroughs. They are good at what they cover, which is how it looks. What none of them covers is the part customers actually judge you on: whether the numbers are theirs, whether they are current, and whether they agree with each other.

A dashboard is a page of claims

Every tile on a dashboard is a statement your company is making about the customer's business. Each one carries five properties, whether or not you designed them:

PropertyThe question it answersWhat goes wrong when nobody decided
ScopeWhose data is this?A cache key without the tenant, or a benchmark that quietly includes other customers
PermissionWho is allowed to see it?The revenue tile is hidden for members in the UI, and returned to them by the API
FreshnessHow old is it?A nightly rollup presented as if it were live
DefinitionWhat does the number mean?Active user means one thing on the dashboard and another in the export
StateWhat if there is nothing, or the query fails?A zero that means no data, an error that means zero, or a blank page

The rest of this post is those five, in the order I check them.

Scope: enforced below the widget, not in it

The obvious leak is a query that forgets the tenant filter, and if your tenancy is enforced at the query layer as described in the isolation boundaries post, the obvious leak is handled. Dashboards have three less obvious ones:

  • The cache key. Dashboard queries are expensive, so they get cached, and dashboard:active_users:30d is a perfectly reasonable key right up to the moment the second tenant loads the page. Every cache key for tenant data starts with the tenant id, generated by a helper rather than typed by hand.
  • Benchmarks. You versus companies like you is a popular widget and it is computed across tenants by definition. Decide what the minimum cohort size is before it ships. A benchmark over three companies in a niche industry tells each of them roughly what the other two are doing.
  • Rollup tables. The nightly job that builds daily_usage runs outside a request, so whatever sets the tenant scope in a web request is not set. This is the same background-job leak covered in tenant-aware queues, caches and logs, and the fix is the same: the job takes the tenant explicitly and the rollup table has a tenant column with the same row-level policy as everything else.

Permission: every widget has its own check

A dashboard is usually built as one page with one permission: can this user see the dashboard? Then the revenue tile is added, someone notices members can see it, and it is wrapped in a role check in the component. The component is not where the data comes from. The endpoint that feeds it still answers anyone who asks.

The structure that holds up is a registry where each widget declares its permission, scope and freshness, and a single server-side loader that refuses to run a widget the user is not allowed to see:

ts
type Widget<T> = {
  id: string;
  permission: Permission;           // checked on the server, per widget
  freshness: "live" | "5m" | "1h" | "daily";
  definition: string;               // shown in the tooltip, and used by the export
  load: (ctx: TenantContext) => Promise<T>;
};

export const widgets = {
  activeUsers: {
    id: "active-users",
    permission: "dashboard.view",
    freshness: "1h",
    definition: "People who signed in at least once in the last 30 days, in your account timezone.",
    load: (ctx) => metrics.activeUsers(ctx, { days: 30 }),
  },
  monthlySpend: {
    id: "monthly-spend",
    permission: "billing.view",
    freshness: "daily",
    definition: "Invoiced and pending charges for the current billing period.",
    load: (ctx) => metrics.monthlySpend(ctx),
  },
} satisfies Record<string, Widget<unknown>>;

export async function loadWidget(id: keyof typeof widgets, ctx: TenantContext) {
  const widget = widgets[id];
  if (!ctx.can(widget.permission)) return { state: "forbidden" as const };

  const key = cacheKey(ctx.tenantId, widget.id);    // tenant first, always
  return cache.remember(key, ttlFor(widget.freshness), async () => ({
    state: "ok" as const,
    value: await widget.load(ctx),
    asOf: new Date().toISOString(),
  }));
}

The client renders whatever comes back, including forbidden, which it renders as nothing at all. Hiding is a layout decision; refusing is a security decision, and the two are kept apart so a redesign cannot undo the second by accident.

Freshness: say how old each number is

Not every number has to be live, and making every number live is how dashboards end up taking eight seconds to load. What every number does need is an honest label. A customer who knows a figure is from last night will not raise a ticket when it disagrees with something that happened this morning.

WidgetTypical sourceReasonable freshnessWhat the label says
Jobs running now, queue depthLive queryLiveNothing, or a pulsing dot
Usage against plan limitMetering counterMinutesUpdated 3 minutes ago
Active users, 30 daysHourly rollup1 hourAs of 09:00
Spend this periodBilling systemDailyAs of yesterday, invoices may take 24 hours to appear
Trends and comparisonsNightly rollupDailyData up to the end of yesterday

The asOf timestamp in the loader above is what drives the label, so the label cannot drift from the cache. The usage-against-limit tile deserves special care: if hitting the limit blocks the customer, the number they see before they hit it has to be close to the number your enforcement uses, or they will be blocked at what their dashboard says is 92%.

Definition: one number, one meaning, everywhere

The opening story is a definition problem, and it is the most common one I find in dashboard reviews. The dashboard, the CSV export, the API and the weekly email all compute active users, written at different times by different people, and they agree on most days. Three rules prevent most of it:

  1. 1Every metric is defined once, in one module, and the dashboard, export, API and emails all call it. If the export needs a list and the dashboard needs a count, they are the same query with a different final step.
  2. 2The definition is shown to the customer. The definition string in the registry goes in the tooltip. If you cannot write the definition in one sentence a customer understands, the number is not ready to be on the dashboard.
  3. 3Time is in the account's timezone. Today for a customer in Sydney ends ten hours before today for your database in UTC. Store the timezone on the account, bucket by it in the rollup, and say which timezone is in use somewhere on the page.

State: empty, loading, partial and broken

Template dashboards are designed with data in them. Your customer's first visit has none, and their worst visit has a query timing out. Each widget needs to handle all of these separately, so one failure does not blank the page:

StateWhat the customer should seeThe mistake
New account, no data yetWhat will appear here and the one action that makes it appearA chart of zeros, which reads as broken
Genuine zeroA zero, with the period it coversRendering it as the empty state, which hides a real signal
Still computingA skeleton for that tile onlyA page-level spinner held until the slowest tile finishes
Partial dataThe figure, with a note that some sources have not syncedPresenting an incomplete total as complete
Query failedThis figure could not load, with a retryShowing 0, which is a false claim, or a stack trace
Not permittedNothingA greyed tile that advertises what the user cannot see

The difference between no data and zero is the one to get right first. A customer whose integrations stopped syncing three days ago should not see a calm row of zeros; they should see that data stopped arriving, and when.

The query that is fine for you and fatal for your biggest customer

Dashboards are developed against a seed tenant with a few hundred rows, and they are fast. Then the largest customer, with forty million events, opens the page, six tiles each run a count(*) over a 90-day range, and the database is busy for everyone. The dashboard is the one page every tenant loads first thing in the morning, so it is also the page most likely to turn one customer's size into everyone's latency.

  • Read aggregates from rollups, built incrementally by tenant, not from raw event tables at request time.
  • Set a statement timeout on dashboard queries that is lower than the one for the rest of the app, and let the tile show its failed state rather than hold a connection.
  • Keep a fixture shaped like your largest tenant, not just your average one, and load the dashboard against it in CI. If you do not know what your largest tenant looks like, that is the first query to run.

Support views and impersonation

Your support team will need to see a customer's dashboard exactly as the customer does. Build that deliberately: an impersonation session that is time-limited, shows a banner that cannot be dismissed, is written to an audit log the customer can see, and cannot perform writes without a second confirmation. The dashboard is usually where impersonation starts, so it is where the guardrails should be tested.

The tests that prove it

ts
describe("dashboard", () => {
  it("never serves one tenant's cached widget to another", async () => {
    await loadWidget("activeUsers", ctxFor(tenantA));
    const b = await loadWidget("activeUsers", ctxFor(tenantB));
    expect(b.value).toEqual(await metrics.activeUsers(ctxFor(tenantB), { days: 30 }));
  });

  it("refuses billing widgets to members on the server", async () => {
    const res = await api.as(tenantA.member).get("/dashboard/widgets/monthly-spend");
    expect(res.body.state).toBe("forbidden");
  });

  it("agrees with the export", async () => {
    const tile = await loadWidget("activeUsers", ctxFor(tenantA));
    const rows = await exports.activeUsers(ctxFor(tenantA), { days: 30 });
    expect(tile.value).toBe(rows.length);
  });

  it("shows the empty state, not zeros, for a new account", async () => {
    const page = await render(Dashboard, { as: freshTenant.owner });
    expect(page.getByText(/connect your first/i)).toBeVisible();
  });
});

The export test is the one that would have prevented the opening story, and the cross-tenant cache test is the one that prevents the much worse story. Both belong next to the rest of your multi-tenant negative tests.

The pre-launch list

  1. 1Every cache key starts with the tenant id, and every rollup table has a tenant column under the same policy as the source table.
  2. 2Every widget has a server-side permission check, and a forbidden widget is not rendered at all.
  3. 3Every number shows how old it is, driven by the same timestamp as the cache.
  4. 4Every metric is defined once, shown to the customer, and shared by the dashboard, export and API.
  5. 5Every widget handles empty, zero, loading, partial, failed and forbidden on its own.
  6. 6The dashboard loads against your largest tenant's shape within your latency budget, with a statement timeout behind it.
  7. 7Impersonation is time-limited, bannered and audited.
Customers forgive a dashboard that is plain. They do not forgive one that disagrees with itself, because after the first time they stop believing any of it.

If you are about to put a dashboard in front of customers, or you have one live that support keeps having to explain, I build and review exactly this layer: tenant scope, per-widget permissions, rollups, metric definitions and the tests that hold them together. Scoped and priced up front. Get a fixed-scope build quote.

Questions people ask

What should a SaaS dashboard include before launch?

Beyond the charts, each widget needs a tenant-scoped query and cache key, a server-side permission check, a visible as-of time, a one-sentence definition shared with the export, and separate states for empty, zero, loading, partial, failed and forbidden. Add impersonation that is time-limited and audited, and test the page against your largest tenant's data volume.

How do you stop a multi-tenant dashboard leaking data between customers?

Enforce tenant scope below the widget, not in it. Generate every cache key with the tenant id first, give rollup tables a tenant column under the same row-level policy as the source data, pass the tenant explicitly to background jobs, and set a minimum cohort size for any benchmark computed across customers. Then test that one tenant's cached widget is never served to another.

Why does my dashboard number not match the CSV export?

Usually because the two compute the same metric separately, with different time windows, timezones or filters. Define each metric once in one module that the dashboard, export, API and emails all call, show that definition to the customer in a tooltip, bucket by the account's timezone, and add a test asserting the dashboard figure equals the export's row count.

Should SaaS dashboard data be real time?

Only where the customer acts on it immediately, such as running jobs or usage against a plan limit. Most figures can come from hourly or nightly rollups, which keeps the page fast. What every figure needs is an honest label saying how old it is, driven by the same timestamp as the cache, so a stale number is never mistaken for a live one.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading