Skip to content

Designing the SaaS Dashboard

Aman Kumar Singh8 min read
Part 23 of 40From the Full Stack SaaS Masterclass series
Designing the SaaS Dashboard — article by Aman Kumar Singh

In Handling File Uploads Securely we made sure users can safely get files into the system. The dashboard is where they land right after, and it's usually the first screen a paying customer sees every single day. That makes it one of the highest-impact pages in the product: slow or wrong, and the impression sticks regardless of how good the rest of the app is.

This is part of the Full Stack SaaS Masterclass, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. A dashboard looks like a UI problem, but what actually determines whether it holds up in production happens in the data layer and the rendering strategy, not the CSS.

I'll scope this article to the architecture: fetching and rendering a page that pulls from several data sources without a waterfall of sequential requests, letting users customize what they see without over-engineering it, and keeping the numbers reasonably fresh without a real-time system you don't need yet.

Why the dashboard is a different engineering problem

Most pages in a SaaS app map cleanly to one resource. A settings page reads one organization row. An invoice page reads one invoice. The dashboard doesn't work that way. It's an aggregation of several unrelated things: usage counts, recent activity, a couple of charts, maybe a billing summary, each backed by a different table or even a different service.

That creates two problems worth solving up front. First, if every widget makes its own round trip and the frontend waits for all of them before painting anything, the page's perceived load time is bounded by the slowest widget, not the average one. Second, every query has to be scoped to the current organization, and it's easy to get that right in nine places and miss it in the tenth, which surfaces as one customer seeing another customer's numbers.

Neither is solved by picking a chart library. Both come down to deciding, up front, how data flows from the database to the screen.

Rendering strategy: let widgets load independently

The instinct on a data-heavy page is to fetch everything in one request, wait for it, then render. That's simple, and it's the wrong tradeoff here, because a single slow query, usually the one doing the heaviest aggregation, ends up blocking the whole page.

With Next.js and React Server Components, the better shape is to treat each widget as its own async boundary and let them stream in independently:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UsageWidget } from './widgets/usage-widget';
import { RecentActivityWidget } from './widgets/recent-activity-widget';
import { BillingSummaryWidget } from './widgets/billing-summary-widget';
import { WidgetSkeleton } from './widgets/widget-skeleton';

export default function DashboardPage() {
  return (
    <div className="dashboard-grid">
      <Suspense fallback={<WidgetSkeleton />}>
        <UsageWidget />
      </Suspense>
      <Suspense fallback={<WidgetSkeleton />}>
        <RecentActivityWidget />
      </Suspense>
      <Suspense fallback={<WidgetSkeleton />}>
        <BillingSummaryWidget />
      </Suspense>
    </div>
  );
}

Each widget component does its own data fetch as a server component and resolves on its own schedule:

// app/dashboard/widgets/usage-widget.tsx
import { getUsageSummary } from '@/lib/api/dashboard';
import { getCurrentOrganizationId } from '@/lib/auth/session';

export async function UsageWidget() {
  const organizationId = await getCurrentOrganizationId();
  const usage = await getUsageSummary(organizationId);

  return (
    <section className="widget">
      <h2>Usage this month</h2>
      <p>{usage.activeUsers} active users</p>
      <p>{usage.apiCallsUsed} / {usage.apiCallsLimit} API calls</p>
    </section>
  );
}

The shell renders instantly, and each widget streams in as its own query completes rather than being held hostage by another. A fast widget, a cached usage count, appears immediately; a slower one, a report scanning a month of activity, shows a skeleton until it's ready.

The tradeoff is more moving parts: more loading and error states, and a layout that tolerates widgets resolving out of order without jumping around. For more than three or four widgets, that cost is worth paying. For a simple dashboard with two numbers on it, a single server-rendered fetch is still the right call.

The data layer: aggregate on the backend, not in the browser

The other place dashboards go wrong is the data layer. It's tempting to expose a generic query endpoint and let the frontend assemble whatever it needs, but that pushes aggregation logic into the client and usually turns into several requests per widget instead of one. The better pattern is a dedicated aggregation endpoint per widget, doing the joining and summarizing in the database where it belongs.

-- One query, scoped to the tenant, doing the aggregation work in Postgres
select
  count(distinct u.id) filter (where u.last_active_at > now() - interval '30 days') as active_users,
  coalesce(sum(uc.api_calls), 0) as api_calls_used
from organizations o
left join users u on u.organization_id = o.id
left join usage_counters uc
  on uc.organization_id = o.id
  and uc.period_start = date_trunc('month', now())
where o.id = $1
group by o.id;
// dashboard.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';

interface UsageSummary {
  activeUsers: number;
  apiCallsUsed: number;
  apiCallsLimit: number;
}

@Injectable()
export class DashboardService {
  constructor(
    private prisma: PrismaService,
    private redis: RedisService,
  ) {}

  async getUsageSummary(organizationId: string): Promise<UsageSummary> {
    const cacheKey = `dashboard:usage:${organizationId}`;
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached);
    }

    const rows = await this.prisma.$queryRaw<
      { active_users: bigint; api_calls_used: bigint }[]
    >`
      select
        count(distinct u.id) filter (where u.last_active_at > now() - interval '30 days') as active_users,
        coalesce(sum(uc.api_calls), 0) as api_calls_used
      from organizations o
      left join users u on u.organization_id = o.id
      left join usage_counters uc
        on uc.organization_id = o.id
        and uc.period_start = date_trunc('month', now())
      where o.id = ${organizationId}
      group by o.id;
    `;

    const organization = await this.prisma.organization.findUniqueOrThrow({
      where: { id: organizationId },
      select: { apiCallsLimit: true },
    });

    const summary: UsageSummary = {
      activeUsers: Number(rows[0]?.active_users ?? 0),
      apiCallsUsed: Number(rows[0]?.api_calls_used ?? 0),
      apiCallsLimit: organization.apiCallsLimit,
    };

    await this.redis.set(cacheKey, JSON.stringify(summary), 'EX', 60);
    return summary;
  }
}

Two things matter more than the SQL itself. First, organizationId is a query parameter, never read off the client's request body, and it's scoped inside the query itself, so a bug elsewhere can't accidentally return another tenant's row. Second, the result is cached in Redis with a short TTL, turning a query that might touch a fair number of rows into something that runs once a minute per organization, regardless of how many times the widget is requested.

The tradeoff with caching is staleness: a user who just triggered an action expecting to see it reflected immediately might not, for up to the TTL window. For most usage widgets that's a fine trade. For a live count during onboarding, shorten the TTL or invalidate the cache key explicitly when the underlying data changes, rather than defaulting to no caching at all.

Letting users customize their dashboard

Once you have more than a handful of widgets, someone asks for the ability to rearrange or hide them. This is a genuinely useful feature and an easy one to over-build. The version worth shipping first is a simple layout table, not a drag-and-drop widget marketplace with per-widget configuration schemas.

create table dashboard_layouts (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references users(id) on delete cascade,
  widget_id text not null,
  position integer not null,
  is_visible boolean not null default true,
  unique (user_id, widget_id)
);

The frontend fetches this layout alongside the widget list, sorts by position, filters out anything with is_visible = false, and falls back to a sensible default order for a user who has never touched it. Persisting per-user rather than per-organization is deliberate: two people on the same team often care about different numbers, and a single shared layout is a worse default than it looks on paper.

Resist making widgets themselves configurable (custom date ranges, per-widget filters, saved views) until users specifically ask for it. That's a larger feature, and building it speculatively means maintaining configuration UI and validation for options nobody uses yet.

Keeping the numbers fresh without over-building real time

Most dashboard data doesn't need to be real time, and treating it as if it does is a common source of wasted effort. A usage counter that's a minute stale is fine. A revenue chart that updates once when the page loads is fine. Reach for polling or revalidation before reaching for a persistent connection.

Next.js's built-in revalidation, or a client-side interval that refetches a widget every thirty or sixty seconds, covers most dashboard use cases with almost no operational cost. If the app already has a WebSocket layer for something else, like the notification system covered earlier in this series, you might be tempted to push dashboard updates through the same channel. Only do that for widgets where staleness is actually a problem: a live queue depth during an incident, an in-progress import's progress bar. For the rest, keeping a socket connection alive and reconciling out-of-order updates costs more than it buys.

Production pitfalls

The most common bug is a widget query that isn't scoped to the tenant, usually introduced when someone copies an existing query and forgets the where organization_id = $1 clause, or adds it after joining a table that should have been filtered earlier. Scope at the query, not in application code after the fact, so there's no code path that can return the wrong rows.

The second is treating every widget's cache the same way. A billing summary that changes once a month can be cached for hours; an active-user count that changes constantly needs a much shorter TTL or none at all. One global cache duration for every endpoint either leaves some data annoyingly stale or defeats the point of caching the slow-moving data.

The third is forgetting that a dashboard is often the single most-hit page in the app, since users load it on every login and often leave it open in a tab. An unindexed aggregation query that's fine in isolation becomes a real cost once it's running for every active organization on every page load. Check the query plan on anything doing a join or a count(distinct ...) before it ships.

Finally, don't let the dashboard become a dumping ground for every metric anyone asks for. Each widget is a query, a cache key, and UI to maintain. A dashboard with twenty widgets nobody looks at is worse than one with five that people actually use.

Key takeaways

  • A dashboard is fundamentally an aggregation problem: multiple data sources, all tenant-scoped, competing for the same page load.
  • Stream widgets independently with Suspense boundaries so one slow query doesn't block the whole page; only add this once there are enough widgets to justify it.
  • Aggregate in the database and cache the result in Redis with a TTL that matches how fast that specific data actually changes, not a one-size-fits-all duration.
  • Scope every dashboard query to the organization inside the query itself, not as an afterthought in application code.
  • Let users customize layout with a simple position/visibility table before building configurable widgets; most teams never need the latter.
  • Default to polling or short-TTL revalidation over real-time updates; reserve WebSockets for the specific widgets where staleness genuinely matters.

Frequently asked questions

Should dashboard widgets fetch data client-side or server-side?

Server-side, using React Server Components with a Suspense boundary per widget, is the better default. It keeps aggregation logic and tenant scoping in trusted backend code, and lets widgets stream in independently instead of the client chaining several fetches.

How long should I cache dashboard data in Redis?

It depends on how fast the number changes and how much a user cares about seeing it instantly. A usage counter can tolerate a sixty-second TTL with no complaints; a number tied to an action the user just triggered may need a shorter TTL or explicit cache invalidation on write.

Do I need WebSockets to build a good SaaS dashboard?

No. Most dashboard data is fine refreshed every thirty to sixty seconds via polling or revalidation. Reserve a real-time connection for the small number of widgets where a user is actively watching something change, like an import's progress.

How should I let users customize their dashboard layout?

Start with a simple table storing widget ID, position, and visibility per user. That covers reordering and hiding widgets, most of what users ask for, without building a configurable widget system nobody has requested.

What's the most common bug in multi-tenant dashboard queries?

A query that isn't properly scoped to the current organization, usually from copying an existing query and missing the tenant filter, or adding it after a join that already pulled in rows from other tenants. Scope inside the query itself so no code path can leak another tenant's data.

Why does my dashboard feel slow even though each individual query is fast?

Because the page is waiting for every widget to resolve before rendering anything. Even a handful of fast queries running sequentially, or one slightly slow one blocking the rest, adds up. Streaming widgets independently, rather than fetching everything in one blocking request, usually fixes the perceived slowness without touching the queries themselves.

Related articles

Performance Tuning Before Launch — Aman Kumar Singh
Caching Strategies for a SaaS — Aman Kumar Singh
Disaster Recovery and Backups — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.