Web Engineering ·

Building Tenant-Aware Analytics Dashboards in Next.js: Data Isolation, Real-Time Aggregation, and Embedded Visualizations

Analytics in a multi-tenant SaaS app is not just charts. It is data isolation, query performance at scale, and a UI that handles per-tenant context without leaking state. Here is how to build it correctly.

Building Tenant-Aware Analytics Dashboards in Next.js: Data Isolation, Real-Time Aggregation, and Embedded Visualizations

Analytics is one of those features that looks straightforward until you add multi-tenancy. A single-tenant dashboard is just queries plus charts. Multi-tenant analytics is queries plus charts plus data isolation plus per-tenant aggregation state plus the ever-present risk of one tenant’s data leaking into another’s view.

This article covers the full stack: data isolation at the Postgres level, aggregation pipelines that stay fast as tenant data grows, a Next.js UI built with React Server Components and SWR, and the export workflows that every product team eventually asks for.

Why Analytics Is Harder in Multi-Tenant Systems

The obvious concern is isolation: tenant A must never see tenant B’s data. But there are subtler problems.

Query performance degrades differently. In a single-tenant app, slow analytics queries are a scale problem you solve once. In multi-tenant systems, a single power-user tenant with 50M rows can degrade query performance for everyone on the same database, or inflate your cache hit rate calculations in ways that mask problems for smaller tenants.

Metrics are contextually different. A platform-wide “active users” count means nothing to an individual tenant. They want their active users. But you, the platform operator, want cross-tenant rollups. These are fundamentally different queries that need different data paths.

Cache invalidation is per-tenant. You cannot cache the result of a dashboard query globally and serve it to all tenants. Every cache key must be scoped, every invalidation event must be tenant-aware, and your cache storage costs grow linearly with tenant count.

Data Isolation Strategies

Row-Level Security in Postgres

Row-Level Security (RLS) is the foundation. It enforces tenant isolation at the database level, which means application bugs cannot cause cross-tenant data exposure.

-- Enable RLS on your events table
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;

-- Create a policy that filters by the current tenant context
CREATE POLICY tenant_isolation ON analytics_events
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Grant usage (not BYPASSRLS) to your application role
GRANT SELECT ON analytics_events TO app_user;

In your application, set the tenant context before every query session:

// lib/db/tenant-client.ts
import { Pool, PoolClient } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function withTenantClient<T>(
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    // Set the tenant context for RLS policies
    await client.query(
      `SELECT set_config('app.current_tenant_id', $1, true)`,
      [tenantId]
    );
    return await fn(client);
  } finally {
    client.release();
  }
}

The true parameter in set_config scopes the setting to the current transaction. This is important: if you use connection pooling (PgBouncer, Supabase pooler), you need transaction-mode pooling, not session-mode, to ensure the tenant context does not bleed between requests.

Tenant-Scoped Materialized Views

Raw event tables do not support dashboard queries well. A query counting daily active users over 90 days, grouped by feature, will full-scan millions of rows. Materialized views pre-aggregate this work.

-- Pre-aggregate daily event counts per tenant per feature
CREATE MATERIALIZED VIEW tenant_daily_feature_usage AS
SELECT
  tenant_id,
  feature_name,
  date_trunc('day', occurred_at) AS day,
  COUNT(DISTINCT user_id) AS unique_users,
  COUNT(*) AS event_count
FROM analytics_events
WHERE occurred_at >= NOW() - INTERVAL '90 days'
GROUP BY tenant_id, feature_name, date_trunc('day', occurred_at);

CREATE UNIQUE INDEX ON tenant_daily_feature_usage (tenant_id, feature_name, day);

You can refresh this on a schedule (every 15 minutes is common for SaaS dashboards), or trigger incremental updates when new events are ingested. The unique index supports REFRESH MATERIALIZED VIEW CONCURRENTLY, which does not lock reads.

REFRESH MATERIALIZED VIEW CONCURRENTLY tenant_daily_feature_usage;

For tenants with high event volume, consider partitioning the events table by tenant_id or by time range. Partition pruning means Postgres will only scan the relevant partition rather than the entire table.

Pre-Aggregated Rollup Tables

For the most performance-sensitive metrics, write aggregated data directly into rollup tables as events arrive, rather than computing them on read:

CREATE TABLE tenant_metric_rollups (
  tenant_id UUID NOT NULL,
  metric_name TEXT NOT NULL,
  granularity TEXT NOT NULL, -- 'hour', 'day', 'week'
  bucket TIMESTAMPTZ NOT NULL,
  value NUMERIC NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (tenant_id, metric_name, granularity, bucket)
);

The tradeoff: rollup tables are fast to read but require accurate write-time logic. If your aggregation logic has a bug, you need to backfill. Materialized views are easier to correct (just fix the query and refresh), but they have higher read-time compute costs.

Use rollup tables for metrics you display in real time (last-hour active sessions, current conversion funnel). Use materialized views for historical reports (90-day retention, cohort analysis).

Real-Time Aggregation Pipelines

Event Ingestion

Events should be written to a fast ingest path, not directly to your analytics tables inside the user request cycle:

// app/api/track/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { publishEvent } from '@/lib/queue/publisher';

const eventSchema = z.object({
  event: z.string(),
  properties: z.record(z.unknown()),
  timestamp: z.string().datetime().optional(),
});

export async function POST(req: NextRequest) {
  const tenantId = req.headers.get('x-tenant-id');
  if (!tenantId) {
    return NextResponse.json({ error: 'Missing tenant context' }, { status: 400 });
  }

  const body = eventSchema.parse(await req.json());

  // Publish to a queue (SQS, BullMQ, Postgres LISTEN/NOTIFY)
  // Do not block the user request on database writes
  await publishEvent({
    tenantId,
    event: body.event,
    properties: body.properties,
    occurredAt: body.timestamp ?? new Date().toISOString(),
  });

  return NextResponse.json({ ok: true });
}

A background worker processes the queue, writes to the events table, and updates rollup tables atomically:

// workers/event-processor.ts
import { withTenantClient } from '@/lib/db/tenant-client';

interface RawEvent {
  tenantId: string;
  event: string;
  properties: Record<string, unknown>;
  occurredAt: string;
}

export async function processEvent(raw: RawEvent): Promise<void> {
  await withTenantClient(raw.tenantId, async (client) => {
    await client.query('BEGIN');

    // Insert into events table
    await client.query(
      `INSERT INTO analytics_events (tenant_id, event_name, properties, occurred_at)
       VALUES ($1, $2, $3, $4)`,
      [raw.tenantId, raw.event, raw.properties, raw.occurredAt]
    );

    // Upsert into hourly rollup
    const bucket = new Date(raw.occurredAt);
    bucket.setMinutes(0, 0, 0);

    await client.query(
      `INSERT INTO tenant_metric_rollups
         (tenant_id, metric_name, granularity, bucket, value)
       VALUES ($1, $2, 'hour', $3, 1)
       ON CONFLICT (tenant_id, metric_name, granularity, bucket)
       DO UPDATE SET value = tenant_metric_rollups.value + 1,
                     updated_at = NOW()`,
      [raw.tenantId, raw.event, bucket.toISOString()]
    );

    await client.query('COMMIT');
  });
}

Incremental Updates vs. Full Recomputation

For most metrics, incremental updates (write-time aggregation) are preferable. Full recomputation (refresh a materialized view every N minutes) is simpler to implement correctly but wastes compute on tenants with low event volume. The hybrid approach: use incremental updates for rollup tables, schedule full refreshes of materialized views only for complex metrics that are hard to aggregate incrementally (cohort analysis, funnel conversion with multi-touch attribution).

Building the Dashboard UI

Server-Side Data Fetching with React Server Components

The initial dashboard load should use React Server Components to fetch data server-side and avoid a loading waterfall:

// app/[tenantSlug]/dashboard/page.tsx
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
import { MetricCards } from '@/components/dashboard/metric-cards';
import { FeatureUsageChart } from '@/components/dashboard/feature-usage-chart';
import { getTenantFromSlug } from '@/lib/tenants';
import { getTenantMetrics } from '@/lib/analytics/queries';

interface Props {
  params: { tenantSlug: string };
  searchParams: { from?: string; to?: string; tz?: string };
}

export default async function DashboardPage({ params, searchParams }: Props) {
  const tenant = await getTenantFromSlug(params.tenantSlug);

  const timezone = searchParams.tz ?? 'UTC';
  const from = searchParams.from ?? getDefaultFrom(timezone);
  const to = searchParams.to ?? getDefaultTo(timezone);

  // These run in parallel on the server
  const [metrics, featureUsage] = await Promise.all([
    getTenantMetrics(tenant.id, { from, to, timezone }),
    getTenantFeatureUsage(tenant.id, { from, to, timezone }),
  ]);

  return (
    <DashboardShell tenant={tenant}>
      <MetricCards metrics={metrics} />
      <FeatureUsageChart initialData={featureUsage} tenantId={tenant.id} />
    </DashboardShell>
  );
}

The server-rendered initial state means the user sees real data on first paint, not a skeleton that loads after hydration.

Real-Time Updates with SWR

For charts that need to stay fresh without a full page reload, use SWR with a polling interval:

// components/dashboard/feature-usage-chart.tsx
'use client';

import useSWR from 'swr';
import { useSearchParams } from 'next/navigation';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import type { FeatureUsagePoint } from '@/lib/analytics/types';

interface Props {
  initialData: FeatureUsagePoint[];
  tenantId: string;
}

const fetcher = (url: string) => fetch(url).then(r => r.json());

export function FeatureUsageChart({ initialData, tenantId }: Props) {
  const searchParams = useSearchParams();
  const from = searchParams.get('from') ?? '';
  const to = searchParams.get('to') ?? '';

  const { data } = useSWR<FeatureUsagePoint[]>(
    `/api/tenants/${tenantId}/analytics/feature-usage?from=${from}&to=${to}`,
    fetcher,
    {
      fallbackData: initialData,
      refreshInterval: 60_000, // refresh every minute
      revalidateOnFocus: false,
    }
  );

  return (
    <ResponsiveContainer width="100%" height={300}>
      <AreaChart data={data}>
        <XAxis dataKey="day" tickFormatter={formatDay} />
        <YAxis />
        <Tooltip />
        <Area
          type="monotone"
          dataKey="unique_users"
          stroke="#6366f1"
          fill="#6366f180"
        />
      </AreaChart>
    </ResponsiveContainer>
  );
}

function formatDay(value: string): string {
  return new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

Timezone Handling in Date Range Pickers

Timezone handling is where most dashboards get it wrong. Your users are distributed. “Last 7 days” should mean the last 7 days in their local timezone, not UTC. This is especially painful for SaaS products with US West Coast users: midnight UTC is 4pm their previous day.

// lib/analytics/date-utils.ts
import { TZDate } from '@date-fns/tz';
import { startOfDay, endOfDay, subDays } from 'date-fns';

export function getDateRangeInTimezone(
  days: number,
  timezone: string
): { from: string; to: string } {
  const now = new TZDate(new Date(), timezone);
  const from = startOfDay(subDays(now, days - 1));
  const to = endOfDay(now);

  return {
    from: from.toISOString(),
    to: to.toISOString(),
  };
}

Store the user’s selected timezone in the URL (?tz=America/Los_Angeles) so that links are shareable and the server-side render uses the same timezone as the client-side display.

Progressive Loading Patterns for Dashboard Performance

Skeleton Screens

Ship a skeleton that matches your chart layout exactly. Generic spinners create layout shift when real content loads:

// components/dashboard/chart-skeleton.tsx
export function ChartSkeleton({ height = 300 }: { height?: number }) {
  return (
    <div
      className="animate-pulse rounded-lg bg-gray-100"
      style={{ height }}
      aria-label="Loading chart"
    />
  );
}

Use React Suspense boundaries to isolate which sections of the dashboard can render independently:

// app/[tenantSlug]/dashboard/page.tsx (with Suspense)
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/dashboard/chart-skeleton';

export default async function DashboardPage({ params, searchParams }: Props) {
  const tenant = await getTenantFromSlug(params.tenantSlug);

  return (
    <DashboardShell tenant={tenant}>
      <Suspense fallback={<MetricCardsSkeleton />}>
        <MetricCardsLoader tenantId={tenant.id} searchParams={searchParams} />
      </Suspense>
      <Suspense fallback={<ChartSkeleton height={300} />}>
        <FeatureUsageLoader tenantId={tenant.id} searchParams={searchParams} />
      </Suspense>
    </DashboardShell>
  );
}

Each Loader component is an async Server Component that fetches its own data. Suspense boundaries mean the page starts streaming HTML as soon as the shell renders, and each section fills in as its data resolves.

Lazy-Loaded Chart Sections

Charts below the fold should not block the initial render. Use next/dynamic with a SSR-disabled chart component:

import dynamic from 'next/dynamic';
import { ChartSkeleton } from '@/components/dashboard/chart-skeleton';

const RetentionCohortChart = dynamic(
  () => import('@/components/dashboard/retention-cohort-chart'),
  {
    loading: () => <ChartSkeleton height={400} />,
    ssr: false, // Recharts and Tremor require a browser environment
  }
);

Data Pagination for Tables

Event-level tables should paginate. Fetching 10,000 rows to render a table is the most common dashboard performance mistake:

// app/api/tenants/[tenantId]/analytics/events/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withTenantClient } from '@/lib/db/tenant-client';

export async function GET(
  req: NextRequest,
  { params }: { params: { tenantId: string } }
) {
  const url = new URL(req.url);
  const page = parseInt(url.searchParams.get('page') ?? '1', 10);
  const pageSize = Math.min(parseInt(url.searchParams.get('pageSize') ?? '50', 10), 200);
  const offset = (page - 1) * pageSize;

  const result = await withTenantClient(params.tenantId, async (client) => {
    const [rows, countResult] = await Promise.all([
      client.query(
        `SELECT event_name, properties, occurred_at
         FROM analytics_events
         ORDER BY occurred_at DESC
         LIMIT $1 OFFSET $2`,
        [pageSize, offset]
      ),
      client.query(`SELECT COUNT(*) FROM analytics_events`),
    ]);

    return {
      events: rows.rows,
      total: parseInt(countResult.rows[0].count, 10),
      page,
      pageSize,
    };
  });

  return NextResponse.json(result);
}

Cap pageSize at 200 on the server. Clients should not be able to request arbitrary page sizes that could lock the database.

CSV and PDF Export

Every analytics dashboard eventually gets a “can you export this?” request. Build it into the API layer from the start.

CSV Export

Stream the response rather than buffering all rows in memory:

// app/api/tenants/[tenantId]/analytics/export/route.ts
import { NextRequest } from 'next/server';
import { withTenantClient } from '@/lib/db/tenant-client';
import { stringify } from 'csv-stringify';
import { Readable } from 'stream';

export async function GET(
  req: NextRequest,
  { params }: { params: { tenantId: string } }
) {
  const url = new URL(req.url);
  const from = url.searchParams.get('from') ?? '';
  const to = url.searchParams.get('to') ?? '';

  const stream = new ReadableStream({
    async start(controller) {
      const client = await withTenantClient(params.tenantId, async (c) => c);

      const cursor = client.query(
        new (await import('pg')).Cursor(
          `SELECT event_name, occurred_at, properties
           FROM analytics_events
           WHERE occurred_at BETWEEN $1 AND $2
           ORDER BY occurred_at DESC`,
          [from, to]
        )
      );

      const csvStringifier = stringify({ header: true });
      csvStringifier.on('data', (chunk: Buffer) => controller.enqueue(chunk));
      csvStringifier.on('end', () => controller.close());

      const readBatch = () => {
        cursor.read(500, (err, rows) => {
          if (err || rows.length === 0) {
            csvStringifier.end();
            return;
          }
          rows.forEach(row => csvStringifier.write(row));
          readBatch();
        });
      };

      readBatch();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/csv',
      'Content-Disposition': `attachment; filename="analytics-export-${params.tenantId}.csv"`,
    },
  });
}

Streaming avoids loading millions of rows into Node.js heap. The Postgres cursor reads in 500-row batches, feeds the CSV stringifier, and the response streams to the client incrementally.

PDF Export

PDF generation is CPU-intensive. Do not do it in a serverless function that has a 30-second timeout. Instead, queue a PDF job and notify the user when it is ready:

// lib/exports/pdf-job.ts
import { queue } from '@/lib/queue/client';

export async function enqueuePdfExport(params: {
  tenantId: string;
  reportType: string;
  dateRange: { from: string; to: string };
  requestedBy: string;
}): Promise<string> {
  const jobId = crypto.randomUUID();

  await queue.add('pdf-export', {
    jobId,
    ...params,
  });

  return jobId;
}

A worker uses Puppeteer or a headless Chromium approach to render the dashboard at a fixed URL, capture it as PDF, upload to S3 or R2, and email the user a signed download link. This is slower but avoids timeout issues and gives you a proper async UX pattern.

Tradeoffs at a Glance

ApproachRead PerformanceWrite ComplexityCorrectness Risk
Raw table queries with RLSLowLowLow (RLS enforces isolation)
Materialized viewsHighMediumMedium (stale until refresh)
Rollup tables (write-time)Very highHighHigh (bug requires backfill)
Hybrid (rollups + mat views)HighHighMedium (two sources of truth)

For most SaaS products at Series A and below, materialized views refreshed every 5-15 minutes hit the right point on this curve. Rollup tables are worth the complexity only when you have tenants with millions of daily events and sub-second dashboard load requirements.

Production Notes

Connection pooling and RLS. PgBouncer in session mode will not work with set_config safely across requests. Use transaction mode, or use Supabase’s built-in pooler which handles tenant context correctly.

Cache keying. If you cache dashboard API responses in Redis, your cache key must include tenantId, the date range, and the timezone. A cache miss on the wrong tenant’s data is a data leak.

Index strategy. Your materialized view index should be on (tenant_id, day) not just (day). Postgres will use a partial scan per tenant only if the tenant ID is the leading column of the index.

Monitoring aggregation lag. Track the delta between MAX(occurred_at) in your rollup tables and NOW(). Alert if it exceeds two minutes. Silent aggregation failures are the hardest bugs to catch in this architecture.

The architecture described here scales from a few hundred tenants to tens of thousands. The core decision that matters most is made early: write-time aggregation or read-time computation. Everything else is implementation detail.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.