Activation as a number you can move, not a feeling you have
A typed event tracker on every meaningful step, a per-tenant activation score computed from the events, and a Server Component dashboard that surfaces the funnel without leaving your stack. Founders stop guessing where new users drop off; the funnel becomes the artefact the team optimises against every week.
The problem
Most SaaS does not know where its new users get stuck
The pattern that loses customers is this. A user signs up, sees a generic welcome screen, gets lost in the dashboard, and never returns. The founder feels something is off, opens Mixpanel or PostHog, finds three months of events fired from copy-pasted snippets that no one currently understands, and gives up. Six months later, churn is the number that wakes someone up at night. We instrument the activation funnel as a first-class part of the codebase: typed events, server-side emission, a per-tenant activation score and a dashboard the team actually reads. Activation becomes the metric the product gets optimised against, not the gut feeling of the founder.
Our approach
The six steps of an activation tracking build
Each step closes one piece of the funnel that founders typically guess at. The deliverable is a working activation dashboard a founder can open Monday morning and act on without an analyst in the loop.
- 01
Define the activation event
What does an activated user look like on this product? We agree on a single, testable definition ("created their first project", "invited a second user", "connected a Stripe account") and write it down. The definition is the contract every other step is measured against.
- 02
Map the funnel steps
Between signup and activation, there are five to ten meaningful steps the user passes through. Each step gets a typed event name, a Zod schema for properties, and an owner. The map lives in source control as a typed config; new steps are PRs, not Slack threads.
- 03
Instrument server-side, not client-side
Every event fires from a Server Action after the database write succeeds. The browser never decides whether an event happened. Ad blockers, network errors and client-state bugs stop deciding what the dashboard sees.
- 04
Store events with tenant scope
Events land in Postgres with `tenant_id`, `user_id`, `event_name`, `properties` and `created_at`. Row-level security scopes reads per tenant. The same table feeds the customer-side activity view and the admin-side funnel.
- 05
Compute the activation score
A nightly job derives a per-tenant activation score from the event stream: percent of funnel steps reached, time-to-activation, drop-off step. The score is a row in `tenant_metrics`, queryable from the admin and shown to the customer.
- 06
Build the funnel dashboard
A Server Component dashboard reads `tenant_metrics` and renders the funnel: cohort by signup week, drop-off per step, activation curve over time. The page renders in one round-trip; the founder reads it Monday morning.
What we deliver
Activation definition and funnel map
One typed file in source control naming every event, its properties, its owner, and the activation criterion. Everyone reads the same definition.
Server-side event tracker
A typed `track(event, properties)` helper called from Server Actions after the database write. Zod validates the properties; the helper writes to Postgres and optionally forwards to the analytics vendor.
Postgres events table
`events` table with tenant_id, user_id, event_name, properties (jsonb), created_at. RLS scopes reads to the tenant. Indexes per (tenant_id, event_name, created_at).
Per-tenant metrics computation
A nightly job that aggregates events per tenant into `tenant_metrics` (activation_score, funnel_step_pct, time_to_activation_hours, drop_off_step).
Activation score for the customer
A panel on the customer dashboard showing their activation score and the next step recommended. Honest, never gamified beyond what is real.
Admin funnel dashboard
Cohorts by signup week, drop-off per step, time-to-activation distribution, segment comparisons. The page a founder opens Monday morning.
A/B test framework
Edge Config flags on funnel steps, exposure events emitted automatically, results aggregated per cohort. Tests are configuration, not code changes.
Onboarding email sequence
Lifecycle emails wired into the transactional provider, triggered by funnel-step events, with one CTA each and a clear unsubscribe.
In-app activation prompts
Contextual tooltips and empty states that point the user to the next funnel step. Triggered by the same events the dashboard reads from.
PostHog or Plausible bridge
The same event helper writes to Postgres and forwards to PostHog or Plausible when configured. The internal table is the source of truth; the analytics tool is the discovery surface.
Cohort export
Admin can export a cohort to CSV (signup week, plan, activation status, drop-off step) for ad-hoc analysis that the dashboard does not cover.
Documentation for the funnel
A short markdown doc in the repo explaining each event, when it fires, what it means, and how to add a new one. The next engineer adds events without breaking the funnel.
A typed server-side tracker, a Server Action that fires it, and the funnel query
Four files compose the full activation-tracking pattern. A typed event registry with Zod schemas, a `track()` helper called from Server Actions, a nightly aggregation that writes to `tenant_metrics`, and the funnel query the dashboard reads.
Most analytics integrations scatter event calls across the client side, hope for the best, and produce a dashboard nobody trusts. Production activation tracking looks like the four files below: an event registry typed by Zod, a track() helper called from Server Actions, a nightly aggregation writing to tenant_metrics, and the funnel query the dashboard reads. Server-side from end to end; the source of truth in Postgres; the vendor (PostHog or Plausible) downstream.
1. The typed event registry
Every event has a name, a properties schema, and an owner. Adding an event is a PR that updates this file; missing it leaves the helper untypable at the 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. The track() helper
A typed function called from any Server Action. It validates the properties at runtime, writes to Postgres, and forwards to the analytics vendor when configured. If validation fails it throws; we would rather find the bug than write a malformed event.
// 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)
// Write to our own events table first; this is the source of truth.
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
// Forward to PostHog if configured. Failure here is logged but never
// throws; the customer-facing dashboard depends on the Postgres write,
// not on the third-party reach.
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. A Server Action that fires an event
The event fires AFTER the database write succeeds. The two operations are wrapped in a try / catch so a partial state cannot leave the event written without the corresponding row, or vice versa.
// 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 }
// Fire the activation event only after the DB write succeeded.
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. The nightly aggregation and the funnel query
The aggregation computes per-tenant metrics every night and writes them to tenant_metrics. The dashboard reads from tenant_metrics (cheap, indexed) instead of replaying the event stream every page load.
-- 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. What this composes
Server Actions fire events as they succeed. The events table is the source of truth; row-level security scopes reads to tenants automatically. The nightly aggregation derives per-tenant metrics that the dashboard reads in one query. PostHog or Plausible receive a copy for exploration; the internal table is what the team optimises against.
Activation stops being a feeling the founder has and becomes a number on a dashboard the team opens Monday morning. The funnel becomes the artefact every product decision argues against; the next ten experiments have a target instead of a vibe.
Related stacks
Frequently asked questions
PostHog, Mixpanel, Amplitude, or our own table?
Our own Postgres table is the source of truth; the vendor is the discovery surface. The internal table survives vendor changes, integrates with the admin dashboard for free, and respects row-level security automatically. We forward events to PostHog or Plausible where the team wants to explore beyond the prebuilt dashboard.
Client-side or server-side tracking?
Server-side, almost always. Client-side fires get blocked by ad blockers, lost to flaky networks and confused by bot traffic. A Server Action that just succeeded knows for certain the event happened; the tracker fires from there with no ambiguity. Client-side is reserved for purely UI signals (a tooltip dismissed) that have no server effect.
How do you define activation?
With the founder, in one scoping call. We pick a single, testable event that correlates with paid retention in your specific product. It is rarely "signed up"; it is usually "completed the first meaningful action". The definition gets written down and becomes the contract every metric reports against.
How fast does the dashboard ship?
A useful first version (event tracker, funnel map, score computation, dashboard with the top five funnel steps) ships in one to two weeks. A/B framework, in-app prompts and the analytics-vendor bridge add another one to two weeks. The scoping phase tells you which pieces matter for your stage.
How do you handle privacy and consent?
Events are tied to a logged-in user or a tenant; no anonymous user fingerprinting. The consent banner controls whether events forward to the analytics vendor; internal events for product analytics happen regardless because they are a legitimate-interest processing under GDPR. We document the consent model in the privacy policy.
Can the customer see their own activation data?
Yes. The same `events` and `tenant_metrics` tables feed a customer-side activation panel, scoped to their tenant via row-level security. Transparency is a feature, not a leak: the customer sees what we see about their own funnel.
How does this stay maintainable as the product grows?
The event registry is typed and centralised. Adding an event is a PR that updates the registry, the Server Action that fires it, and the funnel config; CI catches typos. The dashboard reads from `tenant_metrics` not from individual event tables, so adding events does not break existing charts.
Tell us about your funnel
A scoping call, a concrete number in the first reply, no agency theater. Useful activation tracking in one to two weeks.