Build a Usage Analytics Dashboard

End-to-end recipe β€” from Prisma schema to deployed feature. Covers database, API, frontend, permissions, charts, and tests.

This recipe builds a Usage Analytics Dashboard β€” a dashboard page showing a 30-day time series of API call usage per tenant. It covers every layer of the stack and can be used as a template for any data-heavy dashboard feature.

What you'll build:

  • A UsageSnapshot Prisma model for daily aggregations
  • A GET /api/v1/analytics/usage Hono endpoint backed by @nebutra/metering
  • A React Server Component page with loading/error states
  • A responsive Recharts area chart with dark-mode-aware token colors
  • RBAC gating with the analytics:read permission
  • Sidebar navigation entry
  • Vitest unit test for the API handler
  • Playwright E2E test for the page
  • A Storybook story for the chart component

Prerequisites: A running development environment (pnpm dev) and a Postgres database reachable at DATABASE_URL.


Phase 1: Database

Add the UsageSnapshot model to the Prisma schema. This stores pre-aggregated daily counts β€” cheaper to query than raw ClickHouse data for historical charts.

// packages/platform/db/prisma/schema.prisma

model UsageSnapshot {
  id         String   @id @default(cuid())
  tenantId   String
  date       DateTime @db.Date
  meterId    String           // e.g. "api_calls", "storage_bytes"
  value      BigInt
  createdAt  DateTime @default(now())

  @@unique([tenantId, date, meterId])
  @@index([tenantId, meterId, date])
}
pnpm db:generate
pnpm db:migrate --name add-usage-snapshot

Phase 2: API endpoint

Create the analytics route in the api-gateway. The endpoint reads the last 30 days of data from @nebutra/metering (ClickHouse) and returns a time series array.

// backends/gateway/src/routes/analytics.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requirePermission } from "@nebutra/permissions";
import { getMetering } from "@nebutra/metering";
import { getCurrentTenant } from "@nebutra/tenant";

const analyticsRouter = new Hono();

const usageQuerySchema = z.object({
  meterId: z.string().default("api_calls"),
  days:    z.coerce.number().int().min(1).max(90).default(30),
});

analyticsRouter.get(
  "/usage",
  requirePermission("analytics:read"),
  zValidator("query", usageQuerySchema),
  async (c) => {
    const { meterId, days } = c.req.valid("query");
    const { tenantId } = getCurrentTenant();

    const metering = await getMetering();
    const since = new Date();
    since.setDate(since.getDate() - days);

    const series = await metering.getTimeSeries(tenantId, meterId, {
      from: since,
      to: new Date(),
      granularity: "day",
    });

    return c.json({
      success: true,
      data: {
        meterId,
        tenantId,
        series: series.map((point) => ({
          date:  point.timestamp.toISOString().split("T")[0],
          value: Number(point.value),
        })),
      },
    });
  }
);

export { analyticsRouter };
// backends/gateway/src/index.ts
import { analyticsRouter } from "./routes/analytics";

app.route("/api/v1/analytics", analyticsRouter);

requirePermission is Hono middleware from @nebutra/permissions. It reads the JWT from the Authorization header and throws a 403 if the resolved role does not include the requested scope.


Phase 3: TypeScript types

After adding the endpoint, regenerate the typed API client so the frontend gets full type safety.

pnpm generate:api-types

This runs the openapi-typescript pipeline (script: scripts/generate-api-types.ts), reading backends/gateway/openapi.json and writing apps/web/src/lib/api/types.generated.ts. The typed client (built on openapi-fetch) is available from:

// Server Components / route handlers (with Clerk JWT forwarded):
import { getTypedApi } from "@/lib/api/client";
const api = await getTypedApi();

// Client Components:
import { browserApiClient } from "@/lib/api/browser-client";

Phase 4: Frontend β€” Server Component

mkdir -p apps/web/src/app/\(dashboard\)/analytics
// apps/web/src/app/(dashboard)/analytics/page.tsx
import { Suspense } from "react";
import { PageHeader } from "@nebutra/ui/layout";
import { LoadingState, ErrorState } from "@nebutra/ui/layout";
import { requireAuth, getTenantContext } from "@/lib/auth";
import { getTypedApi } from "@/lib/api/client";
import { AnalyticsChart } from "./_components/analytics-chart";

export const metadata = { title: "Analytics" };

async function AnalyticsData() {
  const { tenantId } = await getTenantContext();
  const api = await getTypedApi();

  const { data, error } = await api.GET("/api/v1/analytics/usage", {
    params: { query: { meterId: "api_calls", days: 30 } },
    next: { revalidate: 300 },   // cache for 5 minutes
  });

  if (error || !data?.success) {
    return <ErrorState message="Failed to load analytics data" />;
  }

  return <AnalyticsChart series={data.data.series} />;
}

export default async function AnalyticsPage() {
  await requireAuth();

  return (
    <div className="space-y-6">
      <PageHeader
        title="Analytics"
        description="API usage over the last 30 days"
      />
      <Suspense fallback={<LoadingState message="Loading usage data…" />}>
        <AnalyticsData />
      </Suspense>
    </div>
  );
}
// apps/web/src/app/(dashboard)/analytics/loading.tsx
import { LoadingState } from "@nebutra/ui/layout";
export default function Loading() {
  return <LoadingState message="Loading analytics…" />;
}
// apps/web/src/app/(dashboard)/analytics/error.tsx
"use client";
import { ErrorState } from "@nebutra/ui/layout";
export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return <ErrorState message={error.message} onRetry={reset} />;
}

Phase 5: Chart component

The chart component is a Client Component (Recharts requires the browser). It reads colors from CSS variables so it automatically adapts to light/dark mode and the active theme preset.

mkdir -p apps/web/src/app/\(dashboard\)/analytics/_components
// apps/web/src/app/(dashboard)/analytics/_components/analytics-chart.tsx
"use client";

import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";
import { AnimateIn } from "@nebutra/ui/components";

interface DataPoint {
  date: string;
  value: number;
}

interface AnalyticsChartProps {
  series: DataPoint[];
}

function formatDate(dateStr: string): string {
  return new Date(dateStr).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
  });
}

function formatValue(value: number): string {
  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
  if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
  return String(value);
}

export function AnalyticsChart({ series }: AnalyticsChartProps) {
  return (
    <AnimateIn preset="emerge" inView>
      <div className="rounded-xl border border-[var(--neutral-7)] bg-[var(--neutral-1)] p-6 shadow-sm">
        <h2 className="mb-4 text-sm font-medium text-[var(--neutral-11)]">
          API Calls β€” Last 30 Days
        </h2>
        <ResponsiveContainer width="100%" height={280}>
          <AreaChart data={series} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
            <defs>
              <linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%"  stopColor="var(--brand-primary)" stopOpacity={0.25} />
                <stop offset="95%" stopColor="var(--brand-primary)" stopOpacity={0} />
              </linearGradient>
            </defs>
            <CartesianGrid
              strokeDasharray="3 3"
              stroke="var(--neutral-7)"
              vertical={false}
            />
            <XAxis
              dataKey="date"
              tickFormatter={formatDate}
              tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
              axisLine={false}
              tickLine={false}
            />
            <YAxis
              tickFormatter={formatValue}
              tick={{ fill: "var(--neutral-11)", fontSize: 12 }}
              axisLine={false}
              tickLine={false}
              width={48}
            />
            <Tooltip
              contentStyle={{
                background: "var(--neutral-2)",
                border: "1px solid var(--neutral-7)",
                borderRadius: "8px",
                color: "var(--neutral-12)",
                fontSize: "13px",
              }}
              labelFormatter={formatDate}
              formatter={(value: number) => [formatValue(value), "API Calls"]}
            />
            <Area
              type="monotone"
              dataKey="value"
              stroke="var(--brand-primary)"
              strokeWidth={2}
              fill="url(#usageGradient)"
              dot={false}
              activeDot={{ r: 4, fill: "var(--brand-primary)" }}
            />
          </AreaChart>
        </ResponsiveContainer>
      </div>
    </AnimateIn>
  );
}

All Recharts color values use CSS variables ("var(--brand-primary)") β€” never hardcoded hex. This ensures the chart adapts to dark mode and theme preset changes without any extra code.


Phase 6: Permission gating

// packages/iam/permissions/src/roles.ts
export const ROLE_PERMISSIONS = {
  OWNER:  ["*"],
  ADMIN:  ["analytics:read", "analytics:export", /* …other admin scopes */],
  MEMBER: ["analytics:read"],
  VIEWER: [],                   // viewers cannot see analytics
} as const;

The requirePermission("analytics:read") middleware added in Phase 2 is sufficient. It returns HTTP 403 for any role that does not include the scope.

// apps/web/src/app/(dashboard)/analytics/page.tsx
import { PlanGate } from "@/components/plan-gate";

// Inside the page component, wrap the content:
<PlanGate
  plans={["starter", "pro", "enterprise"]}
  fallback={
    <EmptyState
      icon="lock"
      title="Analytics not available on free plan"
      description="Upgrade to Starter or higher to access usage analytics."
      action={<UpgradeButton />}
    />
  }
>
  <Suspense fallback={<LoadingState message="Loading usage data…" />}>
    <AnalyticsData />
  </Suspense>
</PlanGate>

Phase 7: Sidebar navigation

// apps/web/src/components/sidebar/nav-items.ts
export const mainNavItems: NavItem[] = [
  // ... existing items
  {
    title: "Analytics",
    href:  "/analytics",
    icon:  "chart-line",
    group: "product",
    plans: ["starter", "pro", "enterprise"],  // hide from free plan
  },
];

Phase 8: Tests

Vitest unit test β€” API handler

// backends/gateway/src/routes/analytics.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { testClient } from "hono/testing";
import { analyticsRouter } from "./analytics";

vi.mock("@nebutra/metering", () => ({
  getMetering: vi.fn().mockResolvedValue({
    getTimeSeries: vi.fn().mockResolvedValue([
      { timestamp: new Date("2025-01-01"), value: BigInt(1500) },
      { timestamp: new Date("2025-01-02"), value: BigInt(2300) },
    ]),
  }),
}));

vi.mock("@nebutra/tenant", () => ({
  getCurrentTenant: vi.fn().mockReturnValue({ tenantId: "org_test_123" }),
}));

vi.mock("@nebutra/permissions", () => ({
  requirePermission: () => async (_c: unknown, next: () => Promise<void>) => next(),
}));

describe("GET /usage", () => {
  it("returns a time series for the requested meter", async () => {
    const client = testClient(analyticsRouter);
    const res = await client.usage.$get({ query: { meterId: "api_calls", days: "30" } });

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.data.series).toHaveLength(2);
    expect(body.data.series[0]).toMatchObject({ date: "2025-01-01", value: 1500 });
  });

  it("defaults to api_calls meter when meterId is omitted", async () => {
    const client = testClient(analyticsRouter);
    const res = await client.usage.$get({ query: { days: "7" } });
    expect(res.status).toBe(200);
  });
});

Run with:

pnpm --filter @nebutra/gateway test

Playwright E2E test

// apps/web/e2e/analytics.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Analytics dashboard", () => {
  test.beforeEach(async ({ page }) => {
    // Sign in as a member with analytics:read permission
    await page.goto("/sign-in");
    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Password").fill(process.env.E2E_TEST_PASSWORD!);
    await page.getByRole("button", { name: "Sign in" }).click();
    await page.waitForURL("/dashboard");
  });

  test("renders the analytics page with a chart", async ({ page }) => {
    await page.goto("/analytics");

    await expect(page.getByRole("heading", { name: "Analytics" })).toBeVisible();
    await expect(page.getByText("API Calls β€” Last 30 Days")).toBeVisible();

    // Recharts renders an SVG β€” verify it is present
    const chart = page.locator("svg.recharts-surface");
    await expect(chart).toBeVisible();
  });

  test("shows upgrade prompt for free-plan users", async ({ page }) => {
    // Sign in as free-plan user
    await page.goto("/sign-in");
    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Password").fill(process.env.E2E_TEST_PASSWORD!);
    await page.getByRole("button", { name: "Sign in" }).click();

    await page.goto("/analytics");
    await expect(page.getByText("Analytics not available on free plan")).toBeVisible();
  });
});

Run with:

pnpm --filter @nebutra/web e2e

Phase 9: Storybook story

Create a story so the chart can be developed and reviewed in isolation.

// apps/storybook/src/stories/AnalyticsChart.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { AnalyticsChart } from "../../web/src/app/(dashboard)/analytics/_components/analytics-chart";

// Generate 30 days of synthetic data
function generateSeries(days = 30) {
  return Array.from({ length: days }, (_, i) => {
    const date = new Date();
    date.setDate(date.getDate() - (days - i));
    return {
      date:  date.toISOString().split("T")[0],
      value: Math.floor(Math.random() * 5000) + 500,
    };
  });
}

const meta: Meta<typeof AnalyticsChart> = {
  title:     "Patterns/AnalyticsChart",
  component: AnalyticsChart,
  tags:      ["autodocs"],
  parameters: {
    layout: "padded",
  },
};
export default meta;

type Story = StoryObj<typeof AnalyticsChart>;

export const Default: Story = {
  args: { series: generateSeries(30) },
};

export const ShortRange: Story = {
  args: { series: generateSeries(7) },
};

export const Empty: Story = {
  args: { series: [] },
};

export const HighVolume: Story = {
  args: {
    series: generateSeries(30).map((p) => ({ ...p, value: p.value * 1000 })),
  },
};

View in Storybook:

pnpm --filter @nebutra/storybook dev
# β†’ http://localhost:6006 β†’ Patterns/AnalyticsChart

Summary

You've built a complete, production-ready feature end-to-end:

PhaseArtifact
1UsageSnapshot Prisma model
2GET /api/v1/analytics/usage Hono endpoint
3Auto-generated TypeScript types
4AnalyticsPage React Server Component
5AnalyticsChart Recharts component with token colors
6analytics:read RBAC scope + plan gating
7Sidebar nav entry
8Vitest unit test + Playwright E2E test
9Storybook story with synthetic data

Use this recipe as a template when building any data-driven dashboard section.


How is this guide?

Edit on GitHub

Last updated on

On this page