Si vous avez déjà essayé de greffer des analytics sur une base Postgres existante, vous connaissez la douleur. Les requêtes qui alimentent les tableaux de bord n'ont rien à voir avec les requêtes qui font tourner votre produit, et dès que votre table d'événements dépasse quelques millions de lignes, chaque agrégation devient une petite crise cardiaque. Tinybird renverse la table. C'est une plateforme analytique en temps réel managée, bâtie sur ClickHouse, conçue pour les ingénieurs produit qui veulent ingérer des événements en streaming et les exposer via des endpoints SQL versionnés en quelques minutes, pas en quelques semaines.
Dans ce tutoriel, vous allez bâtir un tableau de bord analytique SaaS en temps réel pour un produit fictif. Vous allez canaliser les vues de pages et les événements de fonctionnalités depuis une application Next.js 15, les transformer avec des pipes Tinybird, exposer les résultats sous forme d'endpoints API authentifiés, et tracer des graphiques en direct avec Recharts. À la fin, vous aurez un système qui gère confortablement des centaines de millions d'événements avec des requêtes de tableau de bord sous la seconde.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20 ou plus récent installé
- Les bases de Next.js 15 (App Router, Server Components)
- Une connaissance SQL de base (SELECT, GROUP BY, JOIN)
- Un compte Tinybird (le plan gratuit Build suffit)
- Un éditeur de code (VS Code recommandé)
Vous devez aussi être à l'aise avec la lecture de TypeScript. Connaître ClickHouse aide mais n'est pas requis puisque Tinybird abstrait l'essentiel de la complexité opérationnelle.
Ce que vous allez construire
Un module analytique fonctionnel composé de :
- Un collecteur d'événements qui capture les vues de page et les événements personnalisés depuis une app Next.js
- Un workspace Tinybird avec des sources de données, des vues matérialisées et des pipes
- Trois endpoints SQL : top des pages, utilisateurs actifs quotidiens et flux d'événements en temps réel
- Un tableau de bord Next.js qui affiche les graphiques et se rafraîchit toutes les quelques secondes
- Une couche d'autorisation par tokens qui isole les données par workspace
L'architecture se présente ainsi. L'app Next.js envoie les événements vers l'API Events de Tinybird. Tinybird les stocke dans une source Landing, puis des vues matérialisées agrègent les données. Des pipes exposent ces agrégats comme endpoints JSON. Le tableau de bord Next.js les récupère depuis des server components et les revalide à intervalle court.
Étape 1 : Configuration du projet
Créez un nouveau projet Next.js et installez les dépendances dont vous aurez besoin.
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/nodeAjoutez la configuration Tinybird à votre environnement. Créez un fichier .env.local à la racine du projet.
# .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_hereVous remplirez les vrais tokens d'ici peu. Pour l'instant, créez un client typé que nous réutiliserons partout dans l'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}`);
}L'indice revalidate: 5 indique à Next.js de mettre en cache les réponses pendant cinq secondes, ce qui est le point idéal pour un tableau de bord temps réel qui n'a pas besoin d'une fraîcheur à la milliseconde.
Étape 2 : Créer le workspace Tinybird et la source de données
Connectez-vous à votre compte Tinybird, créez un workspace nommé saas-analytics, puis installez le CLI Tinybird pour gérer le schéma comme du code.
pip install tinybird-cli
tb auth --token YOUR_ADMIN_TOKEN
tb workspace lsDéfinissez maintenant la source Landing. C'est là où atterrit chaque événement brut. Créez un fichier dans 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"Quelques choix méritent une explication. La clé de tri commence par workspace_id parce que chaque requête de tableau de bord est cantonnée à un seul tenant, et ClickHouse peut sauter des partitions entières lorsque le préfixe de tri correspond. Le TTL de 180 jours expire les événements bruts automatiquement tout en gardant les agrégats matérialisés intacts, c'est ainsi qu'on reste sur un plan bon marché sans perdre les tendances à long terme.
Poussez la source de données vers Tinybird.
tb push tinybird/datasources/events_landing.datasourceTinybird vous donnera une URL d'ingestion et copiera un token dans votre presse-papiers. Collez le token dans .env.local sous TINYBIRD_INGEST_TOKEN.
Étape 3 : Envoyer des événements depuis Next.js
Ajoutez une route d'ingestion côté serveur dans 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 });
}La validation n'est pas négociable. ClickHouse est permissif face aux dérives de schéma, mais si vous laissez passer du JSON arbitraire, vous le paierez plus tard quand une faute de frappe créera des millions de lignes de déchets.
Exposez maintenant un petit hook client pour que les composants React puissent émettre des événements.
// 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]
);
}Utilisez-le depuis n'importe quel composant client :
"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>
);
}Envoyez une poignée d'événements depuis votre machine de dev et vérifiez qu'ils apparaissent dans l'interface Tinybird sous Data Sources, puis events_landing, puis Operations. Si vous les voyez arriver, vous êtes prêt pour l'agrégation.
Étape 4 : Construire des vues matérialisées pour les agrégations
Un tableau de bord naïf scanne toute la table landing à chaque requête. Mignon à mille lignes, douloureux à cent millions. La bonne approche est de pré-agréger à l'ingestion via des vues matérialisées. Créez-en une pour les vues de pages par jour.
Créez 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"Puis créez un pipe matérialisé dans 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_dailyPoussez les deux vers Tinybird.
tb push tinybird/datasources/page_views_daily.datasource
tb push tinybird/pipes/mv_page_views_daily.pipe --populateLe drapeau --populate remplit la vue matérialisée à partir des données existantes. Désormais, chaque nouvel événement page_view alimente l'agrégat automatiquement. Répétez le même schéma pour tout rollup dont vous avez besoin : une source daily_active_users indexée sur le jour et le workspace, une feature_events_daily pour traquer quelles fonctionnalités sont utilisées, et ainsi de suite. La discipline est simple. Les événements bruts vont dans landing. Tout ce que vous interrogez de façon répétée appartient à une vue matérialisée.
Étape 5 : Exposer des endpoints SQL via les pipes
Un pipe est l'unité de composition SQL de Tinybird. Chaque pipe a un ou plusieurs nodes, et le dernier node est exposé comme endpoint JSON. Créez 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) }}Le marqueur % active la syntaxe de templating de Tinybird. Le garde required=True sur workspace_id est la colonne vertébrale de la sécurité. Un frontend mal configuré ne peut pas demander les données d'un autre tenant parce que l'endpoint refuse de tourner sans ce paramètre.
Poussez-le et récupérez le token de lecture.
tb push tinybird/pipes/top_pages.pipe
tb token lsCopiez le token étiqueté read dans .env.local sous TINYBIRD_READ_TOKEN. Testez maintenant l'endpoint depuis le playground Tinybird.
GET /v0/pipes/top_pages.json?workspace_id=demo&start_date=2026-05-01&end_date=2026-05-25Si vous recevez du JSON avec des chemins et des compteurs de vues, le pipeline de données fonctionne de bout en bout.
Créez deux pipes supplémentaires sur le même modèle : daily_active_users et realtime_feed. Le flux temps réel est intéressant parce qu'il interroge directement la table landing, triée par timestamp, limitée aux 100 dernières lignes. Cela donne au tableau de bord une vue "live tail" des événements entrants.
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Étape 6 : Afficher le tableau de bord dans Next.js
Les server components brillent ici parce qu'ils peuvent interroger Tinybird sans exposer votre token de lecture au navigateur. Créez 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>
);
}Notez le Promise.all parallèle et le segment config revalidate = 5. Deux endpoints, un seul aller-retour en latence, et la page entière est entièrement cacheable à l'edge pendant cinq secondes. C'est largement suffisant pour quasiment tous les cas d'usage analytiques SaaS.
Le composant graphique utilise 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 des 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>
);
}Le live feed est une simple liste qui se ré-affiche dès que son parent server component se rafraîchit.
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">Activité en direct</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> sur {r.path}
</span>
<span className="text-gray-500">
{formatDistanceToNow(new Date(r.timestamp), { addSuffix: true })}
</span>
</li>
))}
</ul>
</section>
);
}Lancez npm run dev, déclenchez quelques événements depuis un autre onglet, et regardez le tableau de bord battre la mesure.
Étape 7 : Verrouiller l'accès multi-tenant
Un vrai SaaS expose les analytics à ses propres clients. Vous ne voulez pas que le client A puisse récupérer les événements du client B. Tinybird supporte la sécurité au niveau ligne par token avec une fonctionnalité appelée JWT tokens. L'idée de base est que vous générez un JWT court côté serveur qui épingle la requête à un workspace_id spécifique.
// 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);
}Configurez le secret côté Tinybird, puis appelez mintTinybirdToken chaque fois que vous devez remettre un token au navigateur. Combiné au garde required=True sur chaque pipe, cela vous donne une défense en profondeur : même si un token fuite, il ne peut lire que le workspace auquel il a été cantonné, et seulement pendant dix minutes.
Étape 8 : Déploiement et supervision
Déployez l'app Next.js sur Vercel avec vercel deploy --prod. Ajoutez les trois variables d'environnement dans les paramètres du projet Vercel. Tinybird lui-même tourne dans le cloud, il n'y a donc aucun serveur à gérer de ce côté.
Pour les opérations courantes, surveillez trois signaux dans l'interface Tinybird. D'abord, le débit d'ingestion sur la source events_landing. Si vous voyez des chutes, l'API Events est rate-limitée ou votre client échoue. Ensuite, le retard des vues matérialisées. Tinybird vous montre à quel point l'agrégat trailing est en retard sur le temps réel. S'il grandit, votre agrégation est trop coûteuse et doit être simplifiée. Enfin, la latence des pipes. Chaque endpoint rapporte un p50 et un p99. Si le p99 grimpe au-dessus de 200 ms, il vous manque souvent une colonne d'index dans la clé de tri ou vous scannez trop de partitions parce que votre filtre temps est trop large.
Tester votre implémentation
Parcourez cette checklist avant de livrer :
- Envoyez 10 000 événements avec l'utilitaire Tinybird Mockingbird et confirmez que le tableau de bord se met à jour en cinq secondes
- Frappez les endpoints avec un mauvais workspace_id et confirmez que vous obtenez un résultat vide, pas une erreur
- Basculez entre deux workspaces de démo dans l'UI et vérifiez que les données changent proprement
- Attendez 24 heures et confirmez que les vues matérialisées quotidiennes se peuplent correctement
- Lancez
tb sql "SELECT count() FROM events_landing"pour comparer le nombre de lignes ingérées aux logs de votre application
npx @tinybirdco/mockingbird-cli generate \
--schema "./mockingbird-schema.json" \
--count 10000 \
--target tinybird \
--token $TINYBIRD_INGEST_TOKEN \
--datasource events_landingDépannage
Si des événements disparaissent silencieusement, vérifiez que votre token d'ingestion a les droits d'écriture sur events_landing. Tinybird renvoie 200 OK pour un corps vide même quand le schéma ne correspond pas, ajoutez donc un contrôle sur failed_rows dans la réponse en cas de doute.
Si une vue matérialisée est vide après --populate, la cause la plus fréquente est une clause WHERE qui filtre toutes les lignes historiques. Rejouez la requête comme un simple SELECT sur la table landing pour confirmer qu'elle renvoie des données.
Si la latence d'un endpoint explose, la solution est presque toujours la clé de tri. Assurez-vous que votre filtre le plus sélectif, généralement workspace_id, est la première colonne.
Si le tableau de bord Next.js affiche des données obsolètes, la cause est généralement le cache de données Vercel. Réduisez revalidate à 1 pendant le débogage, puis remontez-le à 5 une fois le pipeline confirmé sain.
Prochaines étapes
Vous avez un pipeline analytique opérationnel. Quelques pistes pour aller plus loin :
- Ajoutez un endpoint SQL pour l'analyse de tunnel avec
windowFunneldans ClickHouse - Streamez les événements directement depuis Cloudflare Workers via le connecteur Tinybird
- Ajoutez de la détection d'anomalies en injectant les agrégats dans un workflow multi-agents n8n
- Superposez un Claude Agent SDK pour que vos coéquipiers interrogent le tableau de bord en langage naturel
- Exportez les agrégats vers Postgres avec la fonctionnalité Tinybird Sinks pour les joindre à vos données opérationnelles
Conclusion
Tinybird fait partie de ces outils qui changent discrètement votre façon de penser l'analytics produit. Au lieu de jeter encore plus d'index sur Postgres ou de payer un fournisseur d'analytics générique qui facture à l'événement, vous gardez la maîtrise de vos schémas, de vos requêtes et de votre budget de latence. Couplé aux server components Next.js, vous obtenez une architecture de tableau de bord à la fois agréable à construire et bon marché à exploiter, même quand votre volume d'événements grimpe d'un ordre de grandeur.
Le pattern de ce tutoriel passe d'un projet personnel à une startup en série B sans changement architectural. Ajoutez des sources de données quand votre taxonomie d'événements grandit, ajoutez des vues matérialisées quand vos patterns de requêtes se stabilisent, et appuyez-vous sur les tokens JWT pour garder les tenants isolés. C'est tout le modèle mental.