writing/tutorial/2026/05
TutorialMay 25, 2026·30 min read

Build a Real-time SaaS Analytics Dashboard with Tinybird and Next.js

Ship a production-grade real-time analytics dashboard for your SaaS using Tinybird's ClickHouse-powered platform and Next.js 15. Ingest millions of events, build sub-second SQL endpoints, and render live charts.

If you have ever tried to bolt analytics onto an existing Postgres database, you already know the pain. The queries that power dashboards are nothing like the queries that power your product, and the moment your event table crosses a few million rows, every aggregation becomes a small heart attack. Tinybird flips this on its head. It is a managed real-time analytics platform built on ClickHouse, designed for product engineers who want to ingest streaming events and expose them through versioned SQL endpoints in minutes, not weeks.

In this tutorial, you will build a real-time SaaS analytics dashboard for a fictional product. You will pipe page views and feature events from a Next.js 15 application, transform them with Tinybird pipes, expose the results as authenticated API endpoints, and render live charts with Recharts. By the end you will have a system that comfortably handles hundreds of millions of events with sub-second dashboard queries.

Prerequisites

Before you start, make sure you have:

  • Node.js 20 or newer installed
  • Next.js 15 fundamentals (App Router, Server Components)
  • Basic SQL knowledge (SELECT, GROUP BY, JOIN)
  • A Tinybird account (the free Build plan is enough)
  • A code editor (VS Code recommended)

You should also be comfortable reading TypeScript. Familiarity with ClickHouse helps but is not required since Tinybird abstracts most of the operational complexity.

What You'll Build

A working analytics module composed of:

  • An event collector that captures page views and custom events from a Next.js app
  • A Tinybird workspace with data sources, materialized views, and pipes
  • Three SQL endpoints: top pages, daily active users, and a real-time event feed
  • A Next.js dashboard that renders charts and refreshes every few seconds
  • A token-based authorization layer that scopes data per workspace

The architecture looks like this. The Next.js app sends events to a Tinybird Events API. Tinybird stores them in a Landing data source, then materialized views roll the data into aggregates. Pipes expose those aggregates as JSON endpoints. The Next.js dashboard fetches them from server components and revalidates on a short interval.

Step 1: Project Setup

Create a fresh Next.js project and install the dependencies you will need.

npx create-next-app@latest saas-analytics --typescript --tailwind --app
cd saas-analytics
npm install @tinybirdco/mockingbird recharts zod date-fns
npm install -D @types/node

Add the Tinybird configuration to your environment. Create a .env.local file in the project root.

# .env.local
NEXT_PUBLIC_TINYBIRD_HOST=https://api.tinybird.co
TINYBIRD_INGEST_TOKEN=your_ingest_token_here
TINYBIRD_READ_TOKEN=your_read_token_here
TINYBIRD_ADMIN_TOKEN=your_admin_token_here

You will fill in the actual tokens shortly. For now, create a typed client helper that we will reuse across the app.

// lib/tinybird.ts
const host = process.env.NEXT_PUBLIC_TINYBIRD_HOST!;
 
export async function tbQuery<T>(
  pipe: string,
  params: Record<string, string | number> = {},
  token = process.env.TINYBIRD_READ_TOKEN!
): Promise<{ data: T[]; meta: unknown[] }> {
  const search = new URLSearchParams(
    Object.entries(params).reduce<Record<string, string>>((acc, [k, v]) => {
      acc[k] = String(v);
      return acc;
    }, {})
  );
  const url = `${host}/v0/pipes/${pipe}.json?${search.toString()}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
    next: { revalidate: 5 },
  });
  if (!res.ok) throw new Error(`Tinybird ${pipe} failed: ${res.status}`);
  return res.json();
}
 
export async function tbIngest(event: Record<string, unknown>) {
  const url = `${host}/v0/events?name=events_landing`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TINYBIRD_INGEST_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(event),
  });
  if (!res.ok) throw new Error(`Tinybird ingest failed: ${res.status}`);
}

The revalidate: 5 hint tells Next.js to cache responses for five seconds, which is the sweet spot for a real-time dashboard that does not need millisecond freshness.

Step 2: Create the Tinybird Workspace and Data Source

Sign in to your Tinybird account, create a workspace called saas-analytics, then install the Tinybird CLI so you can manage the schema as code.

pip install tinybird-cli
tb auth --token YOUR_ADMIN_TOKEN
tb workspace ls

Now define the Landing data source. This is where every raw event lands. Create a file at tinybird/datasources/events_landing.datasource.

SCHEMA >
    `timestamp` DateTime `json:$.timestamp`,
    `workspace_id` String `json:$.workspace_id`,
    `user_id` String `json:$.user_id`,
    `session_id` String `json:$.session_id`,
    `event` String `json:$.event`,
    `path` String `json:$.path`,
    `referrer` String `json:$.referrer`,
    `country` String `json:$.country`,
    `properties` String `json:$.properties`
 
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
ENGINE_SORTING_KEY "workspace_id, timestamp, event"
ENGINE_TTL "timestamp + INTERVAL 180 DAY"

A few choices worth explaining. The sorting key starts with workspace_id because every dashboard query is scoped to a single tenant, and ClickHouse can skip entire partitions when the sort prefix matches. The TTL of 180 days expires raw events automatically while keeping materialized aggregates intact, which is how you stay on a cheap plan without losing long-term trends.

Push the data source to Tinybird.

tb push tinybird/datasources/events_landing.datasource

Tinybird will give you an ingest URL and copy a token to your clipboard. Paste the token into .env.local as TINYBIRD_INGEST_TOKEN.

Step 3: Send Events from Next.js

Add a server-side ingestion route at app/api/track/route.ts.

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { tbIngest } from "@/lib/tinybird";
 
const eventSchema = z.object({
  workspace_id: z.string().min(1),
  user_id: z.string().min(1),
  session_id: z.string().min(1),
  event: z.string().min(1).max(64),
  path: z.string().default("/"),
  referrer: z.string().default(""),
  country: z.string().default(""),
  properties: z.record(z.unknown()).default({}),
});
 
export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = eventSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.format() }, { status: 400 });
  }
  const event = {
    ...parsed.data,
    timestamp: new Date().toISOString(),
    properties: JSON.stringify(parsed.data.properties),
  };
  await tbIngest(event);
  return NextResponse.json({ ok: true });
}

Validation is non-negotiable. ClickHouse is permissive about schema drift, but if you let arbitrary JSON in, you will pay for it later when a typo creates millions of rows of garbage.

Now expose a tiny client hook so React components can fire events.

// lib/use-track.ts
"use client";
import { useCallback } from "react";
 
export function useTrack(workspaceId: string, userId: string) {
  return useCallback(
    async (event: string, properties: Record<string, unknown> = {}) => {
      const sessionId =
        sessionStorage.getItem("sid") ??
        crypto.randomUUID().replace(/-/g, "").slice(0, 16);
      sessionStorage.setItem("sid", sessionId);
      await fetch("/api/track", {
        method: "POST",
        body: JSON.stringify({
          workspace_id: workspaceId,
          user_id: userId,
          session_id: sessionId,
          event,
          path: window.location.pathname,
          referrer: document.referrer,
          properties,
        }),
      });
    },
    [workspaceId, userId]
  );
}

Use it from any client component:

"use client";
import { useTrack } from "@/lib/use-track";
 
export function UpgradeButton({ workspaceId, userId }: Props) {
  const track = useTrack(workspaceId, userId);
  return (
    <button
      onClick={() => {
        track("upgrade_clicked", { plan: "pro" });
      }}
    >
      Upgrade
    </button>
  );
}

Send a handful of events from your dev machine and verify they appear in the Tinybird UI under Data Sources, then events_landing, then Operations. If you see them flowing in, you are ready to aggregate.

Step 4: Build Materialized Views for Aggregations

A naive dashboard scans the entire landing table on every request. Cute at a thousand rows, painful at a hundred million. The right move is to pre-aggregate on ingest using materialized views. Create one for page views per day.

Create tinybird/datasources/page_views_daily.datasource.

SCHEMA >
    `day` Date,
    `workspace_id` String,
    `path` String,
    `views` AggregateFunction(count, UInt64),
    `uniques` AggregateFunction(uniq, String)
 
ENGINE "AggregatingMergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(day)"
ENGINE_SORTING_KEY "workspace_id, day, path"

Then create a materialized pipe at tinybird/pipes/mv_page_views_daily.pipe.

NODE materialize_node
SQL >
    SELECT
        toDate(timestamp) AS day,
        workspace_id,
        path,
        countState() AS views,
        uniqState(user_id) AS uniques
    FROM events_landing
    WHERE event = 'page_view'
    GROUP BY day, workspace_id, path
 
TYPE MATERIALIZED
DATASOURCE page_views_daily

Push both to Tinybird.

tb push tinybird/datasources/page_views_daily.datasource
tb push tinybird/pipes/mv_page_views_daily.pipe --populate

The --populate flag backfills the materialized view from existing data. Going forward, every new page_view event flows into the aggregate automatically. Repeat the same pattern for any rollup you need: a daily_active_users data source keyed on day and workspace, a feature_events_daily for tracking which features get used, and so on. The discipline is simple. Raw events go to landing. Anything you query repeatedly belongs in a materialized view.

Step 5: Expose SQL Endpoints with Pipes

A pipe is Tinybird's unit of SQL composition. Each pipe has one or more nodes, and the last node is exposed as a JSON endpoint. Create tinybird/pipes/top_pages.pipe.

TOKEN read READ
 
NODE top_pages_node
SQL >
    %
    SELECT
        path,
        countMerge(views) AS views,
        uniqMerge(uniques) AS uniques
    FROM page_views_daily
    WHERE workspace_id = {{ String(workspace_id, required=True) }}
      AND day BETWEEN {{ Date(start_date) }} AND {{ Date(end_date) }}
    GROUP BY path
    ORDER BY views DESC
    LIMIT {{ Int32(limit, 10) }}

The % marker enables Tinybird's templating syntax. The required=True guard on workspace_id is the security backbone. A misconfigured frontend cannot ask for another tenant's data because the endpoint refuses to run without that parameter.

Push it and grab the read token.

tb push tinybird/pipes/top_pages.pipe
tb token ls

Copy the token labeled read into .env.local as TINYBIRD_READ_TOKEN. Now test the endpoint from the Tinybird playground.

GET /v0/pipes/top_pages.json?workspace_id=demo&start_date=2026-05-01&end_date=2026-05-25

If you get JSON back with paths and view counts, the data pipeline is working end to end.

Create two more pipes following the same shape: daily_active_users and realtime_feed. The real-time feed is interesting because it queries the landing table directly, ordered by timestamp, limited to the last 100 rows. That gives the dashboard a "live tail" view of incoming events.

NODE realtime_feed_node
SQL >
    %
    SELECT timestamp, event, path, user_id, country
    FROM events_landing
    WHERE workspace_id = {{ String(workspace_id, required=True) }}
      AND timestamp > now() - INTERVAL 5 MINUTE
    ORDER BY timestamp DESC
    LIMIT 100

Step 6: Render the Dashboard in Next.js

Server components shine here because they can fetch from Tinybird without exposing your read token to the browser. Create app/(dashboard)/page.tsx.

import { tbQuery } from "@/lib/tinybird";
import { TopPagesChart } from "@/components/top-pages-chart";
import { LiveFeed } from "@/components/live-feed";
 
type TopPage = { path: string; views: number; uniques: number };
type FeedRow = { timestamp: string; event: string; path: string };
 
export const revalidate = 5;
 
export default async function Dashboard({
  searchParams,
}: {
  searchParams: { workspace?: string };
}) {
  const workspace = searchParams.workspace ?? "demo";
  const today = new Date().toISOString().slice(0, 10);
  const start = new Date(Date.now() - 30 * 86_400_000)
    .toISOString()
    .slice(0, 10);
 
  const [topPages, feed] = await Promise.all([
    tbQuery<TopPage>("top_pages", {
      workspace_id: workspace,
      start_date: start,
      end_date: today,
      limit: 10,
    }),
    tbQuery<FeedRow>("realtime_feed", { workspace_id: workspace }),
  ]);
 
  return (
    <main className="grid gap-6 p-8 md:grid-cols-2">
      <TopPagesChart rows={topPages.data} />
      <LiveFeed rows={feed.data} />
    </main>
  );
}

Notice the parallel Promise.all and the revalidate = 5 segment config. Two endpoints, one round trip in terms of latency, and the entire page is fully cacheable at the edge for five seconds. That is more than enough freshness for nearly every SaaS analytics use case.

The chart component uses Recharts:

"use client";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
 
export function TopPagesChart({ rows }: { rows: { path: string; views: number }[] }) {
  return (
    <section className="rounded-2xl border p-4">
      <h2 className="mb-3 text-lg font-semibold">Top pages</h2>
      <ResponsiveContainer width="100%" height={320}>
        <BarChart data={rows}>
          <XAxis dataKey="path" tickFormatter={(p) => p.slice(0, 18)} />
          <YAxis allowDecimals={false} />
          <Bar dataKey="views" fill="#2563eb" radius={6} />
        </BarChart>
      </ResponsiveContainer>
    </section>
  );
}

The live feed is a simple list that re-renders whenever its parent server component refreshes.

import { formatDistanceToNow } from "date-fns";
 
export function LiveFeed({ rows }: { rows: { timestamp: string; event: string; path: string }[] }) {
  return (
    <section className="rounded-2xl border p-4">
      <h2 className="mb-3 text-lg font-semibold">Live activity</h2>
      <ul className="space-y-2 text-sm">
        {rows.map((r, i) => (
          <li key={i} className="flex justify-between">
            <span>
              <code className="text-blue-600">{r.event}</code> on {r.path}
            </span>
            <span className="text-gray-500">
              {formatDistanceToNow(new Date(r.timestamp), { addSuffix: true })}
            </span>
          </li>
        ))}
      </ul>
    </section>
  );
}

Run npm run dev, fire a few events from another tab, and watch the dashboard tick along.

Step 7: Lock Down Multi-Tenant Access

A real SaaS exposes analytics back to its own customers. You do not want customer A to be able to fetch customer B's events. Tinybird supports per-token row-level security with a feature called JWT tokens. The basic idea is that you mint a short-lived JWT on the server that pins the request to a specific workspace_id.

// lib/tinybird-jwt.ts
import { SignJWT } from "jose";
 
const secret = new TextEncoder().encode(process.env.TINYBIRD_JWT_SECRET!);
 
export async function mintTinybirdToken(workspaceId: string) {
  return new SignJWT({
    workspace_id: workspaceId,
    scopes: [{ type: "PIPES:READ", resource: "top_pages" }],
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("10m")
    .sign(secret);
}

Configure the secret on the Tinybird side, then call mintTinybirdToken whenever you need to hand a token to a browser. Combined with the required=True guard on every pipe, this gives you defense in depth: even if a token leaks, it cannot read more than the workspace it was scoped to and only for ten minutes.

Step 8: Deploy and Monitor

Deploy the Next.js app to Vercel with vercel deploy --prod. Add the three environment variables in the Vercel project settings. Tinybird itself runs in the cloud so there is no server to manage on that side.

For ongoing operations, watch three signals in the Tinybird UI. First, ingest throughput on the events_landing data source. If you see drops, the Events API is being rate limited or your client is failing. Second, materialized view lag. Tinybird shows you how long the trailing aggregate is behind real time. If it grows, your aggregation is too expensive and needs simplification. Third, pipe latency. Every endpoint reports a p50 and p99. If p99 climbs above 200 ms, you are usually missing an index column in the sorting key or scanning too many partitions because your time filter is too loose.

Testing Your Implementation

Run through this checklist before you ship:

  • Send 10,000 events with the Tinybird Mockingbird helper and confirm the dashboard updates within five seconds
  • Hit the endpoints with a wrong workspace_id and confirm you get an empty result, not an error
  • Switch between two demo workspaces in the UI and verify the data swaps cleanly
  • Wait 24 hours and confirm the daily materialized views populate correctly
  • Run tb sql "SELECT count() FROM events_landing" to compare ingested row counts against your application logs
npx @tinybirdco/mockingbird-cli generate \
  --schema "./mockingbird-schema.json" \
  --count 10000 \
  --target tinybird \
  --token $TINYBIRD_INGEST_TOKEN \
  --datasource events_landing

Troubleshooting

If events disappear silently, check that your ingest token has write permission on events_landing. Tinybird returns 200 OK for an empty body even when the schema mismatches, so add a check on failed_rows in the response when in doubt.

If a materialized view is empty after --populate, the most common cause is a WHERE clause that filters out all historical rows. Replay the query as a plain SELECT against the landing table to confirm it returns data.

If endpoint latency spikes, the workaround is almost always the sorting key. Make sure your most selective filter, usually workspace_id, is the first column.

If the Next.js dashboard renders stale data, the cause is usually the Vercel data cache. Reduce revalidate to 1 during debugging, then dial it back to 5 once you confirm the pipeline is healthy.

Next Steps

You have a working analytics pipeline. A few directions to take it further:

  • Add a SQL endpoint for funnel analysis using windowFunnel in ClickHouse
  • Stream events directly from Cloudflare Workers using the Tinybird Connector
  • Add anomaly detection by piping aggregates into an n8n multi-agent workflow
  • Layer a Claude Agent SDK on top to let teammates ask the dashboard questions in natural language
  • Export aggregates to Postgres with the Tinybird Sinks feature for joining with your operational data

Conclusion

Tinybird is one of those tools that quietly changes how you think about product analytics. Instead of throwing more indexes at Postgres or paying a generic analytics vendor that charges per event, you keep ownership of your schemas, your queries, and your latency budget. Pair it with Next.js server components and you get a dashboard architecture that is both delightful to build and cheap to run, even when your event volume goes up by an order of magnitude.

The pattern in this tutorial scales from a side project to a series B startup without architectural changes. Add data sources as your event taxonomy grows, add materialized views as your query patterns stabilize, and lean on JWT tokens to keep tenants isolated. That is the entire mental model.