J'ai livré une douzaine de dashboards analytics ces 18 derniers mois. Voici l'architecture qui revient à chaque fois — parce qu'elle fonctionne.
Pourquoi Next.js + Supabase
Next.js 14 App Router donne :
- Server Components pour les données initiales (0 loading state)
- Route Handlers pour les API
- Edge Runtime pour les performances
Supabase donne :
- PostgreSQL managé avec Row Level Security
- Realtime via WebSocket natif
- Auth intégrée
- SDK TypeScript first-class
Le tout est déployable sur Vercel en 10 minutes, avec SSL, CDN et auto-scaling inclus.
Structure du projet
src/
├── app/
│ ├── dashboard/
│ │ ├── page.tsx # Server Component — charge les données initiales
│ │ ├── layout.tsx # Layout avec sidebar
│ │ └── components/
│ │ ├── KPICards.tsx # Client Component — realtime via useEffect
│ │ ├── Charts.tsx # Recharts + Server data
│ │ └── DataTable.tsx # Tri, pagination, export CSV
│ └── api/
│ └── metrics/
│ └── route.ts # Endpoint pour les updates manuelles
├── lib/
│ ├── supabase.ts # Client server + client browser
│ └── metrics.ts # Fonctions de calcul KPIs
└── types/
└── dashboard.ts # Types partagés
Schéma Supabase
-- Table principale
create table metrics (
id uuid default gen_random_uuid() primary key,
date date not null unique,
mrr numeric not null default 0,
leads integer not null default 0,
conversion numeric not null default 0,
churn numeric not null default 0,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Index pour les requêtes temporelles
create index metrics_date_idx on metrics(date desc);
-- RLS — lecture publique, écriture authentifiée
alter table metrics enable row level security;
create policy "Public read" on metrics
for select using (true);
create policy "Authenticated write" on metrics
for all using (auth.role() = 'authenticated');
Client Supabase
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
// Client côté serveur (Server Components, Route Handlers)
export const supabaseServer = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY! // service key pour le serveur
)
// Client côté navigateur (Client Components)
export const supabaseBrowser = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
Server Component — chargement initial
// app/dashboard/page.tsx
import { supabaseServer } from '@/lib/supabase'
import KPICards from './components/KPICards'
import Charts from './components/Charts'
export default async function DashboardPage() {
// Données chargées côté serveur — pas de loading state
const { data: metrics } = await supabaseServer
.from('metrics')
.select('*')
.order('date', { ascending: false })
.limit(90) // 3 derniers mois
return (
<div className='space-y-6 p-6'>
<KPICards initialData={metrics ?? []} />
<Charts data={metrics ?? []} />
</div>
)
}
Client Component avec Realtime
// components/KPICards.tsx
'use client'
import { useEffect, useState } from 'react'
import { supabaseBrowser } from '@/lib/supabase'
import type { Metric } from '@/types/dashboard'
interface Props {
initialData: Metric[]
}
export default function KPICards({ initialData }: Props) {
const [metrics, setMetrics] = useState(initialData)
useEffect(() => {
// Subscribe aux changements en temps réel
const channel = supabaseBrowser
.channel('metrics-realtime')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'metrics' },
payload => {
if (payload.eventType === 'INSERT') {
setMetrics(prev => [payload.new as Metric, ...prev])
}
if (payload.eventType === 'UPDATE') {
setMetrics(prev =>
prev.map(m => (m.id === payload.new.id ? (payload.new as Metric) : m))
)
}
}
)
.subscribe()
return () => { supabaseBrowser.removeChannel(channel) }
}, [])
const latest = metrics[0]
const previous = metrics[1]
const delta = (current: number, prev: number) =>
prev > 0 ? ((current - prev) / prev) * 100 : 0
return (
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<KPICard
label='MRR'
value={`${latest?.mrr?.toLocaleString('fr-FR')} €`}
delta={delta(latest?.mrr ?? 0, previous?.mrr ?? 0)}
color='#00C8FF'
/>
{/* ... autres KPIs */}
</div>
)
}
Export CSV intégré
Un feature souvent demandé, simple à implémenter :
function exportToCSV(data: Metric[], filename: string) {
const headers = ['Date', 'MRR', 'Leads', 'Conversion (%)', 'Churn (%)']
const rows = data.map(m => [m.date, m.mrr, m.leads, m.conversion.toFixed(1), m.churn.toFixed(1)])
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
Déploiement Vercel
# Variables d'environnement à configurer dans Vercel
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_KEY=eyJ... # Service key — backend uniquement
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # Anon key — frontend
Avec cette setup, le dashboard est :
- Chargement initial : <100ms (Server Component)
- Updates : temps réel via WebSocket
- Coût infrastructure : ~0€ jusqu'à 50k requêtes/mois (Supabase free tier)
Le piège RLS + Realtime que tout le monde rencontre une fois
La policy "Public read" on metrics for select using (true) fonctionne parfaitement pour les requêtes classiques (select()). Mais Supabase Realtime a ses propres règles : par défaut, les changements ne sont diffusés que si la policy RLS de la table les autorise pour le rôle utilisé par la connexion WebSocket.
Piège concret : si vous changez la policy pour restreindre la lecture (par exemple using (auth.uid() = user_id) sur un dashboard multi-tenant), le select() initial du Server Component continue de fonctionner (il utilise la service key, qui bypass RLS), mais le canal Realtime côté client — qui utilise l'anon key et respecte RLS — arrête silencieusement de recevoir les événements pour les autres utilisateurs. Pas d'erreur, pas de log : les KPIs se figent simplement, et ça peut passer inaperçu plusieurs jours si personne ne regarde le dashboard en continu.
La vérification qui prend 30 secondes et évite ce piège :
-- Realtime doit être explicitement activé par table, en plus de RLS
alter publication supabase_realtime add table metrics;
-- Vérifier que la policy couvre bien le rôle utilisé par le canal realtime
select * from pg_policies where tablename = 'metrics';
Et côté client, ne jamais assumer que l'abonnement fonctionne silencieusement — logger l'état de la souscription permet de détecter le problème immédiatement plutôt que de le découvrir a posteriori :
.subscribe(status => {
if (status === 'CHANNEL_ERROR') {
console.error('Realtime subscription failed — vérifier RLS sur la table metrics')
}
})
Ce qui prend du temps en vrai
La partie technique est rapide. Ce qui prend du temps :
- Définir les bons KPIs — pas ceux qui sont faciles à calculer, ceux qui ont du sens pour le business
- Gérer les edge cases : données manquantes, division par zéro, fuseaux horaires
- UX des filtres : par période, par segment, avec drill-down
Le code de base prend 2-3 jours. La finition UX en prend autant.