Back to Case Studies
High-performance analytics dashboard UI
Frontend Engineering Featured

Building a High-Performance Analytics Dashboard with Tremor and Next.js

Protize Engineering Team Updated
#Tremor #Next.js #Analytics #Dashboard #Performance

Building a High-Performance Analytics Dashboard with Tremor and Next.js

When merchants ask, “How are my transactions doing right now?” they expect millisecond-fast answers.
We built a real-time analytics dashboard that renders time-series payins, payouts, and chargebacks with Tremor UI components on top of Next.js (App Router) and a scalable time-series API.


1) Goals

  1. <10ms p95 render for client interactions (filter/pan/zoom).
  2. Streaming-first UX for “hot” data (last 60 minutes).
  3. Consistent chart contracts across payins, payouts, and chargebacks.
  4. A design system-friendly approach using Tremor + Tailwind.

2) Architecture Overview

High-level flow:

  1. User selects date range + interval.
  2. Client posts filter to a server action.
  3. Server action queries the Time-Series API.
  4. Results are normalized, cached by tags, and streamed back.
  5. Tremor charts animate with new data without reflowing the layout.

3) Data Contract (Time-Series API)

The API returns an array of buckets, each with timestamp, count, amount, and status.
Example (trimmed):

[
  { "timestamp": "2025-11-06T06:15:00.000Z", "count": 87, "amount": 143200, "status": "success" },
  { "timestamp": "2025-11-06T06:30:00.000Z", "count": 102, "amount": 168900, "status": "success" }
]

Normalization step guarantees:


4) Example: Server Action

// app/(dashboard)/actions.ts
"use server";

import "server-only";
import { revalidateTag } from "next/cache";

type Query = {
  merchantId: string;
  from: string; // ISO
  to: string;   // ISO
  interval: "15m" | "1h" | "1d";
  kind: "payins" | "payouts" | "chargebacks";
};

export async function getAnalytics(q: Query) {
  const tag = `analytics:${q.merchantId}:${q.kind}:${q.interval}`;
  const url = `${process.env.ANALYTICS_URL}/v1/series?merchantId=${q.merchantId}&from=${q.from}&to=${q.to}&interval=${q.interval}&kind=${q.kind}`;

  const res = await fetch(url, {
    next: { tags: [tag], revalidate: 60 },
    headers: { Authorization: `Bearer ${process.env.ANALYTICS_TOKEN}` },
  });

  if (!res.ok) {
    throw new Error(`Analytics fetch failed: ${res.status}`);
  }

  const raw = await res.json();
  return normalizeBuckets(raw);
}

// Ensures sorted buckets, backfilled gaps, and numeric coercion
function normalizeBuckets(raw: any[]) {
  // ...implementation specific to your API...
  return raw;
}

export async function revalidateAnalyticsTag(tag: string) {
  revalidateTag(tag);
}

5) Tremor Composition

We used <AreaChart /> for time-series and <BarList /> for categorical breakdowns.

// app/(dashboard)/components/Timeseries.tsx
"use client";

import { Card, AreaChart, Title, Text } from "@tremor/react";

export function Timeseries({ data, title, category = "amount" }: any) {
  return (
    <Card className="rounded-2xl shadow-sm">
      <Title>{title}</Title>
      <Text className="mt-1">Live interval view</Text>
      <AreaChart
        className="mt-4 h-64"
        data={data}
        index="timestamp"
        categories={[category]}
        // no explicit colors to keep theme-compatible
        yAxisWidth={56}
        showLegend={false}
        curveType="monotone"
        autoMinValue
      />
    </Card>
  );
}

6) Performance Techniques


7) Accessibility & UX


8) Results

MetricBeforeAfter
p95 page TTFB480ms120ms
p95 client interactivity340ms85ms
Bundle size (hydrated)620KB290KB
Support tickets (reporting)HighLow

9) What We’d Do Next


Authored by the Protize Engineering Team — November 2025.

← Back to Case Studies