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
UsageSnapshotPrisma model for daily aggregations - A
GET /api/v1/analytics/usageHono 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:readpermission - 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:generatepnpm db:migrate --name add-usage-snapshotPhase 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-typesThis 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 testPlaywright 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 e2ePhase 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/AnalyticsChartSummary
You've built a complete, production-ready feature end-to-end:
| Phase | Artifact |
|---|---|
| 1 | UsageSnapshot Prisma model |
| 2 | GET /api/v1/analytics/usage Hono endpoint |
| 3 | Auto-generated TypeScript types |
| 4 | AnalyticsPage React Server Component |
| 5 | AnalyticsChart Recharts component with token colors |
| 6 | analytics:read RBAC scope + plan gating |
| 7 | Sidebar nav entry |
| 8 | Vitest unit test + Playwright E2E test |
| 9 | Storybook story with synthetic data |
Use this recipe as a template when building any data-driven dashboard section.
How is this guide?
Last updated on