Attivazione come numero che puoi muovere, non come sensazione che hai
Un tracker di eventi tipizzato su ogni passo significativo, uno score di attivazione per tenant calcolato dagli eventi e una dashboard Server Component che fa emergere il funnel senza uscire dal tuo stack. I founder smettono di tirare a indovinare dove cadono i nuovi utenti; il funnel diventa il manufatto su cui il team ottimizza ogni settimana.
Il problema
La maggior parte delle SaaS non sa dove restano bloccati i nuovi utenti
Il pattern che perde i clienti è questo. Un utente si registra, vede una schermata di benvenuto generica, si perde nella dashboard e non torna più. Il founder sente che qualcosa non va, apre Mixpanel o PostHog, trova tre mesi di eventi sparati da snippet copia-incollati che nessuno oggi capisce e si arrende. Sei mesi dopo, il churn è il numero che ti sveglia di notte. Strumentiamo il funnel di attivazione come parte di prima classe del codebase: eventi tipizzati, emissione server-side, uno score di attivazione per tenant e una dashboard che il team legge davvero. L'attivazione diventa la metrica su cui il prodotto si ottimizza, non la sensazione viscerale del founder.
Come lo affrontiamo
I sei passi di una build di tracking attivazione
Ogni passo chiude un pezzo di funnel che di solito i founder tirano a indovinare. La consegna è una dashboard di attivazione che funziona, che un founder può aprire il lunedì mattina e su cui agire senza un analista nel loop.
- 01
Definire l'evento di attivazione
Come si presenta un utente attivato su questo prodotto? Ci accordiamo su una definizione unica, testabile ("ha creato il primo progetto", "ha invitato un secondo utente", "ha collegato un account Stripe") e la scriviamo. La definizione è il contratto su cui ogni altro passo viene misurato.
- 02
Mappare i passi del funnel
Tra signup e attivazione ci sono cinque-dieci passi significativi che l'utente attraversa. Ogni passo riceve un nome evento tipizzato, uno schema Zod per le properties e un owner. La mappa vive nel source control come config tipizzata; i nuovi passi sono PR, non thread Slack.
- 03
Strumentare server-side, non client-side
Ogni evento parte da una Server Action dopo che la scrittura sul database ha avuto successo. Il browser non decide mai se un evento è avvenuto. Ad blocker, errori di rete e bug di stato client smettono di decidere cosa vede la dashboard.
- 04
Salvare gli eventi con scope di tenant
Gli eventi atterrano in Postgres con `tenant_id`, `user_id`, `event_name`, `properties` e `created_at`. Row-level security limita le letture per tenant. La stessa tabella alimenta la view di attività lato cliente e il funnel lato admin.
- 05
Calcolare lo score di attivazione
Un job notturno deriva uno score di attivazione per tenant dallo stream di eventi: percentuale di passi raggiunti, tempo di attivazione, passo di drop-off. Lo score è una riga in `tenant_metrics`, interrogabile dall'admin e mostrata al cliente.
- 06
Costruire la dashboard del funnel
Una dashboard Server Component legge da `tenant_metrics` e rende il funnel: coorte per settimana di signup, drop-off per passo, curva di attivazione nel tempo. La pagina si carica in un round-trip; il founder la legge il lunedì mattina.
Cosa consegniamo
Definizione di attivazione e mappa del funnel
Un file tipizzato nel source control che nomina ogni evento, le sue properties, l'owner e il criterio di attivazione. Tutti leggono la stessa definizione.
Tracker di eventi server-side
Un helper tipizzato `track(event, properties)` chiamato dalle Server Action dopo la scrittura sul database. Zod valida le properties; l'helper scrive su Postgres e opzionalmente inoltra al vendor di analytics.
Tabella events su Postgres
Tabella `events` con tenant_id, user_id, event_name, properties (jsonb), created_at. RLS limita le letture al tenant. Indici per (tenant_id, event_name, created_at).
Computazione delle metriche per tenant
Un job notturno che aggrega gli eventi per tenant in `tenant_metrics` (activation_score, funnel_step_pct, time_to_activation_hours, drop_off_step).
Score di attivazione per il cliente
Un pannello sulla dashboard del cliente che mostra il suo score di attivazione e il prossimo passo consigliato. Onesto, mai gamificato oltre quello che è vero.
Dashboard funnel per l'admin
Coorti per settimana di signup, drop-off per passo, distribuzione del tempo di attivazione, comparazioni per segmento. La pagina che un founder apre il lunedì mattina.
Framework di A/B test
Flag di Edge Config sui passi del funnel, eventi di esposizione emessi in automatico, risultati aggregati per coorte. I test sono configurazione, non modifiche al codice.
Sequenza email di onboarding
Email di lifecycle collegate al provider transazionale, innescate da eventi di passo del funnel, con una CTA ciascuna e un unsubscribe chiaro.
Prompt di attivazione in-app
Tooltip contestuali ed empty state che indirizzano l'utente al prossimo passo del funnel. Innescati dagli stessi eventi da cui legge la dashboard.
Bridge PostHog o Plausible
Lo stesso helper di evento scrive su Postgres e inoltra a PostHog o Plausible quando configurato. La tabella interna è la fonte di verità; il tool di analytics è l'area di esplorazione.
Export delle coorti
L'admin può esportare una coorte in CSV (settimana di signup, piano, stato di attivazione, passo di drop-off) per analisi ad hoc che la dashboard non copre.
Documentazione del funnel
Un breve markdown nel repo che spiega ogni evento, quando scatta, cosa significa e come aggiungerne uno nuovo. Il prossimo ingegnere aggiunge eventi senza rompere il funnel.
Un tracker server-side tipizzato, una Server Action che lo invoca e la query del funnel
Quattro file compongono il pattern completo di tracking attivazione. Un registry di eventi tipizzato con schemi Zod, un helper `track()` chiamato dalle Server Action, un'aggregazione notturna che scrive su `tenant_metrics` e la query del funnel da cui la dashboard legge.
La maggior parte delle integrazioni analytics spara chiamate di evento sparse lato client, spera nel migliore dei mondi e produce una dashboard di cui nessuno si fida. Il tracking di attivazione in produzione assomiglia ai quattro file qui sotto: un registry di eventi tipizzato da Zod, un helper track() chiamato dalle Server Action, un'aggregazione notturna che scrive su tenant_metrics e la query del funnel da cui la dashboard legge. Server-side da capo a fondo; fonte di verità su Postgres; il vendor (PostHog o Plausible) a valle.
1. Il registry di eventi tipizzato
Ogni evento ha un nome, uno schema di properties e un owner. Aggiungere un evento è una PR che aggiorna questo file; non farlo lascia l'helper non tipizzabile al call site.
// src/lib/analytics/events.ts
import { z } from 'zod'
export const EVENT_NAMES = [
'user.signed_up',
'user.verified_email',
'tenant.created',
'project.created',
'invite.sent',
'invite.accepted',
'subscription.started',
'first_value.reached',
] as const
export type EventName = (typeof EVENT_NAMES)[number]
export const EVENT_PROPS = {
'user.signed_up': z.object({
source: z.enum(['organic', 'paid', 'referral', 'direct']).optional(),
plan: z.string().optional(),
}),
'user.verified_email': z.object({
verification_age_minutes: z.number().int().nonnegative(),
}),
'tenant.created': z.object({
plan: z.string(),
seat_count: z.number().int().positive().default(1),
}),
'project.created': z.object({
project_id: z.string().uuid(),
template: z.string().optional(),
}),
'invite.sent': z.object({
invite_id: z.string().uuid(),
role: z.enum(['owner', 'admin', 'member', 'billing']),
}),
'invite.accepted': z.object({
invite_id: z.string().uuid(),
accept_age_hours: z.number().int().nonnegative(),
}),
'subscription.started': z.object({
stripe_subscription_id: z.string(),
plan: z.string(),
}),
'first_value.reached': z.object({
flow: z.string(),
elapsed_minutes: z.number().int().nonnegative(),
}),
} as const satisfies Record<EventName, z.ZodTypeAny>
export type EventProps<T extends EventName> = z.infer<(typeof EVENT_PROPS)[T]>
2. L'helper track()
Una funzione tipizzata chiamata da qualsiasi Server Action. Valida le properties a runtime, scrive su Postgres e inoltra al vendor di analytics quando configurato. Se la validazione fallisce lancia; preferiamo trovare il bug piuttosto che scrivere un evento malformato.
// src/lib/analytics/track.ts
import { adminClient } from '@/lib/supabase/admin'
import { EVENT_PROPS, type EventName, type EventProps } from './events'
interface TrackInput<T extends EventName> {
tenantId: string
userId: string | null
event: T
properties: EventProps<T>
}
export async function track<T extends EventName>(
input: TrackInput<T>,
): Promise<void> {
const schema = EVENT_PROPS[input.event]
const validated = schema.parse(input.properties)
// Scrive nella nostra tabella events come primo passo; è la fonte di verità.
const { error } = await adminClient.from('events').insert({
tenant_id: input.tenantId,
user_id: input.userId,
event_name: input.event,
properties: validated,
})
if (error) throw error
// Inoltra a PostHog se configurato. Il fallimento qui viene loggato ma non
// lanciato; la dashboard customer dipende dalla scrittura Postgres, non
// dall'arrivo al terzo.
if (process.env.POSTHOG_API_KEY) {
void fetch(`${process.env.POSTHOG_HOST}/i/v0/e/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.POSTHOG_API_KEY,
event: input.event,
properties: {
...validated,
$groups: { tenant: input.tenantId },
distinct_id: input.userId ?? input.tenantId,
},
}),
}).catch(() => {})
}
}
3. Una Server Action che fa partire un evento
L'evento parte DOPO che la scrittura sul database è andata bene. Le due operazioni sono dentro un try / catch così uno stato parziale non lascia l'evento scritto senza la riga corrispondente, o viceversa.
// app/[lang]/(app)/projects/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession, getActiveTenantId } from '@/lib/auth/server'
import { track } from '@/lib/analytics/track'
const Input = z.object({
name: z.string().min(1).max(200),
template: z.string().optional(),
})
export async function createProject(
raw: unknown,
): Promise<{ ok: true; projectId: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const tenantId = await getActiveTenantId()
if (!tenantId) return { ok: false, error: 'no active tenant' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
const supabase = await createServerClient()
const { data: project, error } = await supabase
.from('projects')
.insert({
tenant_id: tenantId,
name: parsed.data.name,
template: parsed.data.template ?? null,
created_by: session.userId,
})
.select('id')
.single()
if (error) return { ok: false, error: error.message }
// Spara l'evento di attivazione solo dopo che la scrittura DB è andata bene.
await track({
tenantId,
userId: session.userId,
event: 'project.created',
properties: {
project_id: project.id,
template: parsed.data.template,
},
})
revalidatePath('/projects')
return { ok: true, projectId: project.id }
}
4. L'aggregazione notturna e la query del funnel
L'aggregazione calcola le metriche per tenant ogni notte e le scrive su tenant_metrics. La dashboard legge da tenant_metrics (economico, indicizzato) invece di rieseguire lo stream di eventi ad ogni caricamento pagina.
-- supabase/migrations/0020_tenant_metrics.sql
create table tenant_metrics (
tenant_id uuid primary key references tenants(id) on delete cascade,
activation_score numeric(5,2) not null default 0,
funnel_step_pct jsonb not null default '{}',
time_to_activation_hours integer,
drop_off_step text,
events_total integer not null default 0,
computed_at timestamptz not null default now()
);
create or replace function refresh_tenant_metrics()
returns void
language plpgsql
security definer
as $$
declare
rec record;
begin
for rec in
select t.id as tenant_id, t.created_at as tenant_created_at
from tenants t
loop
with funnel as (
select
event_name,
min(created_at) as first_seen
from events
where tenant_id = rec.tenant_id
group by event_name
),
steps as (
select
(select first_seen from funnel where event_name = 'user.signed_up') as signed_up,
(select first_seen from funnel where event_name = 'project.created') as project_created,
(select first_seen from funnel where event_name = 'invite.accepted') as invite_accepted,
(select first_seen from funnel where event_name = 'first_value.reached') as first_value
)
insert into tenant_metrics (tenant_id, activation_score, funnel_step_pct, time_to_activation_hours, drop_off_step, events_total, computed_at)
select
rec.tenant_id,
case
when first_value is not null then 100
when invite_accepted is not null then 75
when project_created is not null then 50
when signed_up is not null then 25
else 0
end as activation_score,
jsonb_build_object(
'signed_up', signed_up is not null,
'project_created', project_created is not null,
'invite_accepted', invite_accepted is not null,
'first_value', first_value is not null
) as funnel_step_pct,
case
when first_value is not null
then extract(epoch from (first_value - signed_up))::int / 3600
end as time_to_activation_hours,
case
when first_value is null and invite_accepted is not null then 'first_value'
when invite_accepted is null and project_created is not null then 'invite_accepted'
when project_created is null and signed_up is not null then 'project_created'
end as drop_off_step,
(select count(*) from events where tenant_id = rec.tenant_id),
now()
from steps
on conflict (tenant_id) do update set
activation_score = excluded.activation_score,
funnel_step_pct = excluded.funnel_step_pct,
time_to_activation_hours = excluded.time_to_activation_hours,
drop_off_step = excluded.drop_off_step,
events_total = excluded.events_total,
computed_at = now();
end loop;
end;
$$;
// app/admin/funnel/page.tsx
import { Suspense } from 'react'
import { adminClient } from '@/lib/supabase/admin'
async function FunnelData() {
const { data: rows } = await adminClient
.from('tenant_metrics')
.select('tenant_id, activation_score, drop_off_step, time_to_activation_hours, computed_at')
.order('computed_at', { ascending: false })
.limit(200)
if (!rows) return <p>No metrics yet.</p>
return (
<table className="ds-table">
<thead>
<tr>
<th>Tenant</th>
<th>Score</th>
<th>Drop-off</th>
<th>Time to activation</th>
<th>Computed</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.tenant_id}>
<td>{r.tenant_id}</td>
<td>{r.activation_score}</td>
<td>{r.drop_off_step ?? '—'}</td>
<td>{r.time_to_activation_hours ?? '—'}</td>
<td>{new Date(r.computed_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)
}
export default function FunnelPage() {
return (
<Suspense fallback={<p>Loading funnel…</p>}>
<FunnelData />
</Suspense>
)
}
5. Come si tiene insieme tutto questo
Le Server Action sparano eventi quando hanno successo. La tabella events è la fonte di verità; row-level security limita in automatico le letture per tenant. L'aggregazione notturna deriva metriche per tenant che la dashboard legge in una sola query. PostHog o Plausible ricevono una copia per l'esplorazione; la tabella interna è quella su cui il team ottimizza.
L'attivazione smette di essere una sensazione viscerale del founder e diventa un numero su una dashboard che il team apre il lunedì mattina. Il funnel diventa il manufatto contro cui ogni decisione di prodotto argomenta; i prossimi dieci esperimenti hanno un target invece che una semplice intuizione.
Stack correlati
Domande frequenti
PostHog, Mixpanel, Amplitude o tabella nostra?
La nostra tabella Postgres è la fonte di verità; il vendor è l'area di esplorazione. La tabella interna sopravvive ai cambi di vendor, si integra con la dashboard admin gratis e rispetta row-level security in automatico. Inoltriamo gli eventi a PostHog o Plausible quando il team vuole esplorare oltre la dashboard precostruita.
Tracking client-side o server-side?
Server-side, quasi sempre. I fire client-side vengono bloccati dagli ad blocker, persi su reti instabili e confusi dal traffico di bot. Una Server Action appena riuscita sa per certo che l'evento è avvenuto; il tracker parte da lì senza ambiguità. Il client-side resta per i segnali puramente UI (un tooltip chiuso) che non hanno effetto server.
Come definite l'attivazione?
Col founder, in una sola call di scoping. Scegliamo un singolo evento testabile che correla con la retention pagante nel tuo prodotto specifico. Raramente è "si è registrato"; di solito è "ha completato la prima azione significativa". La definizione viene scritta nero su bianco e diventa il contratto su cui ogni metrica risponde.
In quanto tempo esce la dashboard?
Una prima versione utile (tracker di eventi, mappa del funnel, computazione dello score, dashboard con i primi cinque passi) esce in una-due settimane. Il framework A/B, i prompt in-app e il bridge col vendor di analytics aggiungono un'altra settimana o due. La fase di scoping ti dice quali pezzi contano per la tua fase.
Come gestite privacy e consenso?
Gli eventi sono legati a un utente loggato o a un tenant; niente fingerprinting di utenti anonimi. Il banner di consenso controlla se gli eventi vengono inoltrati al vendor di analytics; gli eventi interni per la product analytics avvengono comunque perché sono trattamento di legittimo interesse sotto GDPR. Documentiamo il modello di consenso nella privacy policy.
Il cliente può vedere i propri dati di attivazione?
Sì. Le stesse tabelle `events` e `tenant_metrics` alimentano un pannello di attivazione lato cliente, limitato al suo tenant via row-level security. La trasparenza è una feature, non un leak: il cliente vede quello che vediamo noi sul suo funnel.
Come resta mantenibile mentre il prodotto cresce?
Il registry di eventi è tipizzato e centralizzato. Aggiungere un evento è una PR che aggiorna il registry, la Server Action che lo emette e la config del funnel; la CI intercetta i typo. La dashboard legge da `tenant_metrics`, non dalle singole tabelle di eventi, quindi aggiungere eventi non rompe i grafici esistenti.
Raccontaci il funnel
Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Tracking di attivazione utile in una-due settimane.