Activación como un número que puedes mover, no como una sensación que tienes
Un tracker de eventos tipado en cada paso significativo, un score de activación por tenant calculado a partir de los eventos y un dashboard Server Component que hace surgir el funnel sin salir de tu stack. Los founders dejan de adivinar dónde se quedan colgados los usuarios nuevos; el funnel pasa a ser el artefacto contra el que el equipo optimiza cada semana.
El problema
La mayoría de los SaaS no sabe dónde se quedan atascados los nuevos usuarios
El patrón que pierde clientes es este. Un usuario se registra, ve una pantalla de bienvenida genérica, se pierde en el dashboard y no vuelve. El founder siente que algo no va, abre Mixpanel o PostHog, encuentra tres meses de eventos disparados desde snippets copy-pasted que hoy nadie entiende, y tira la toalla. Seis meses después, el churn es el número que despierta a alguien por la noche. Instrumentamos el funnel de activación como pieza de primera clase del codebase: eventos tipados, emisión en servidor, un score de activación por tenant y un dashboard que el equipo de verdad lee. La activación pasa a ser la métrica contra la que se optimiza el producto, no la corazonada del founder.
Cómo lo abordamos
Los seis pasos de una build de tracking de activación
Cada paso cierra un trozo del funnel que los founders normalmente adivinan. La entrega es un dashboard de activación que funciona, que un founder puede abrir un lunes por la mañana y sobre el que actuar sin un analista en el loop.
- 01
Definir el evento de activación
¿Cómo es un usuario activado en este producto? Acordamos una definición única y testable ("creó su primer proyecto", "invitó a un segundo usuario", "conectó una cuenta de Stripe") y la escribimos. La definición es el contrato contra el que se mide cada otro paso.
- 02
Mapear los pasos del funnel
Entre signup y activación hay cinco o diez pasos significativos por los que pasa el usuario. Cada paso recibe un nombre de evento tipado, un schema Zod para las properties y un owner. El mapa vive en source control como config tipada; los nuevos pasos son PRs, no hilos de Slack.
- 03
Instrumentar en servidor, no en cliente
Cada evento se dispara desde una Server Action después de que la escritura en la base de datos tenga éxito. El navegador nunca decide si un evento ocurrió. Los ad blockers, los errores de red y los bugs de estado en cliente dejan de decidir qué ve el dashboard.
- 04
Guardar los eventos con scope de tenant
Los eventos aterrizan en Postgres con `tenant_id`, `user_id`, `event_name`, `properties` y `created_at`. Row-level security acota las lecturas por tenant. La misma tabla alimenta la vista de actividad del lado cliente y el funnel del lado admin.
- 05
Calcular el score de activación
Un job nocturno deriva un score de activación por tenant a partir del stream de eventos: porcentaje de pasos alcanzados, tiempo hasta activación, paso de drop-off. El score es una fila en `tenant_metrics`, consultable desde admin y mostrada al cliente.
- 06
Construir el dashboard del funnel
Un dashboard Server Component lee de `tenant_metrics` y pinta el funnel: cohorte por semana de signup, drop-off por paso, curva de activación a lo largo del tiempo. La página se renderiza en un round-trip; el founder la lee el lunes por la mañana.
Qué entregamos
Definición de activación y mapa de funnel
Un archivo tipado en source control que nombra cada evento, sus properties, su owner y el criterio de activación. Todos leen la misma definición.
Tracker de eventos del lado servidor
Un helper tipado `track(event, properties)` llamado desde Server Actions tras la escritura en la base de datos. Zod valida las properties; el helper escribe en Postgres y opcionalmente reenvía al vendor de analytics.
Tabla events en Postgres
Tabla `events` con tenant_id, user_id, event_name, properties (jsonb), created_at. RLS acota las lecturas al tenant. Índices por (tenant_id, event_name, created_at).
Cálculo de métricas por tenant
Un job nocturno que agrega los eventos por tenant en `tenant_metrics` (activation_score, funnel_step_pct, time_to_activation_hours, drop_off_step).
Score de activación para el cliente
Un panel en el dashboard del cliente que muestra su score de activación y el siguiente paso recomendado. Honesto, nunca gamificado más allá de lo que es real.
Dashboard de funnel para admin
Cohortes por semana de signup, drop-off por paso, distribución del tiempo hasta activación, comparaciones por segmento. La página que un founder abre el lunes por la mañana.
Framework de A/B testing
Flags de Edge Config sobre los pasos del funnel, eventos de exposición emitidos en automático, resultados agregados por cohorte. Los tests son configuración, no cambios de código.
Secuencia de emails de onboarding
Emails de lifecycle conectados al proveedor transaccional, disparados por eventos de paso del funnel, con un CTA cada uno y un unsubscribe claro.
Prompts de activación en la app
Tooltips contextuales y empty states que dirigen al usuario al siguiente paso del funnel. Disparados por los mismos eventos que lee el dashboard.
Bridge PostHog o Plausible
El mismo helper de evento escribe en Postgres y reenvía a PostHog o Plausible cuando está configurado. La tabla interna es la fuente de verdad; la herramienta de analytics es el campo de exploración.
Export de cohortes
El admin puede exportar una cohorte a CSV (semana de signup, plan, estado de activación, paso de drop-off) para análisis ad hoc que el dashboard no cubre.
Documentación del funnel
Un markdown corto en el repo que explica cada evento, cuándo se dispara, qué significa y cómo añadir uno nuevo. El siguiente ingeniero añade eventos sin romper el funnel.
Un tracker tipado en servidor, una Server Action que lo invoca y la query del funnel
Cuatro archivos componen el patrón completo de tracking de activación. Un registry de eventos tipado con schemas Zod, un helper `track()` llamado desde Server Actions, una agregación nocturna que escribe en `tenant_metrics` y la query del funnel que el dashboard lee.
La mayoría de las integraciones de analytics dispersa las llamadas de evento por el lado cliente, espera lo mejor y produce un dashboard del que nadie se fía. El tracking de activación en producción se parece a los cuatro archivos de abajo: un registry de eventos tipado por Zod, un helper track() llamado desde Server Actions, una agregación nocturna escribiendo en tenant_metrics, y la query del funnel que el dashboard lee. Servidor de punta a punta; la fuente de verdad en Postgres; el vendor (PostHog o Plausible) aguas abajo.
1. El registry de eventos tipado
Cada evento tiene un nombre, un schema de properties y un owner. Añadir un evento es una PR que actualiza este archivo; si no se hace, el helper queda no tipable en el 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. El helper track()
Una función tipada llamada desde cualquier Server Action. Valida las properties en runtime, escribe en Postgres y reenvía al vendor de analytics cuando está configurado. Si la validación falla lanza; preferimos encontrar el bug antes que escribir un evento malformado.
// 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)
// Escribe primero en nuestra tabla events; esa es la fuente de verdad.
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
// Reenvía a PostHog si está configurado. Un fallo aquí queda loggeado pero
// nunca lanza; el dashboard del cliente depende de la escritura en Postgres,
// no de llegar al tercero.
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 que dispara un evento
El evento se dispara DESPUÉS de que la escritura en la base de datos tiene éxito. Las dos operaciones van dentro de un try / catch para que un estado parcial no deje el evento escrito sin la fila correspondiente, 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 }
// Dispara el evento de activación solo después de que la escritura DB ha ido bien.
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. La agregación nocturna y la query del funnel
La agregación calcula las métricas por tenant cada noche y las escribe en tenant_metrics. El dashboard lee de tenant_metrics (barato, indexado) en vez de reproducir el stream de eventos en cada carga de página.
-- 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. Cómo encaja todo esto
Las Server Actions disparan eventos según tienen éxito. La tabla events es la fuente de verdad; row-level security acota las lecturas al tenant de manera automática. La agregación nocturna deriva métricas por tenant que el dashboard lee en una sola query. PostHog o Plausible reciben una copia para exploración; la tabla interna es contra la que el equipo optimiza.
La activación deja de ser una sensación del founder y pasa a ser un número en un dashboard que el equipo abre el lunes por la mañana. El funnel se convierte en el artefacto contra el que cada decisión de producto argumenta; los próximos diez experimentos tienen un objetivo en vez de una corazonada.
Stacks relacionados
Preguntas frecuentes
PostHog, Mixpanel, Amplitude o tabla propia?
Nuestra tabla Postgres es la fuente de verdad; el vendor es el campo de exploración. La tabla interna sobrevive a los cambios de vendor, se integra gratis con el dashboard de admin y respeta row-level security en automático. Reenviamos eventos a PostHog o Plausible cuando el equipo quiere explorar más allá del dashboard predefinido.
Tracking en cliente o en servidor?
Servidor, casi siempre. Los disparos en cliente los bloquean los ad blockers, se pierden en redes inestables y se confunden con tráfico de bots. Una Server Action recién completada sabe con certeza que el evento ocurrió; el tracker dispara desde ahí sin ambigüedad. El cliente se queda para señales puramente UI (un tooltip cerrado) que no tienen efecto en servidor.
¿Cómo definís la activación?
Con el founder, en una sola llamada de scoping. Elegimos un único evento testable que correlaciona con la retention pagante en tu producto específico. Rara vez es "se registró"; suele ser "completó la primera acción significativa". La definición queda por escrito y pasa a ser el contrato contra el que reporta cada métrica.
¿En cuánto sale el dashboard?
Una primera versión útil (tracker de eventos, mapa de funnel, cálculo de score, dashboard con los cinco primeros pasos) sale en una o dos semanas. El framework A/B, los prompts in-app y el bridge con el vendor de analytics añaden otra una o dos semanas. La fase de scoping te dice qué piezas importan para tu fase.
¿Cómo manejáis la privacidad y el consentimiento?
Los eventos están ligados a un usuario logueado o a un tenant; nada de fingerprinting de usuarios anónimos. El banner de consentimiento controla si los eventos se reenvían al vendor de analytics; los eventos internos para product analytics ocurren igual porque son tratamiento de interés legítimo bajo GDPR. Documentamos el modelo de consentimiento en la política de privacidad.
¿El cliente puede ver sus propios datos de activación?
Sí. Las mismas tablas `events` y `tenant_metrics` alimentan un panel de activación del lado cliente, acotado a su tenant vía row-level security. La transparencia es una feature, no una fuga: el cliente ve lo que vemos nosotros sobre su propio funnel.
¿Cómo se mantiene esto mantenible cuando el producto crece?
El registry de eventos es tipado y centralizado. Añadir un evento es una PR que actualiza el registry, la Server Action que lo dispara y la config del funnel; la CI atrapa los typos. El dashboard lee de `tenant_metrics`, no de tablas de eventos individuales, así que añadir eventos no rompe los gráficos existentes.
Cuéntanos tu funnel
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Tracking de activación útil en una o dos semanas.