> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/recipes/signup-funnel.md).

# Track a signup funnel

A signup funnel is the canonical "did this work?" question. We'll instrument five steps:

```
$pageview (landing)  →  clicked_cta  →  viewed_signup_form  →  signup  →  activated
```

…then query the conversion rate of each step and break it down by traffic source.

## Step 1 — instrument the funnel

### Landing page (auto)

The browser SDK already fires `$pageview` on load. Nothing to do.

### CTA click

```tsx
import { track } from "@millimetric/track";

export function HeroCTA() {
  return (
    <button
      onClick={() => track("clicked_cta", { location: "hero", label: "Start free" })}
    >
      Start free
    </button>
  );
}
```

### Form view (when the signup form renders)

```tsx
import { useEffect } from "react";
import { track } from "@millimetric/track";

export function SignupForm() {
  useEffect(() => {
    track("viewed_signup_form", { variant: "v2" });
  }, []);
  // ...
}
```

### Signup itself

Send from the **server** so failed clientside submits don't pollute the funnel. Tie it back to the visitor with the `anonymous_id` they were carrying.

```ts
// app/api/signup/route.ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  const { email, plan, anonymous_id } = await req.json();

  // ...your signup logic, get user.id back...

  // Stitch anonymous → known
  await fetch(`${process.env.AOA_HOST}/v1/identify`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AOA_SK}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      anonymous_id,
      user_id: user.id,
      traits: { plan, email }
    })
  });

  track({
    event: "signup",
    anonymous_id,
    user_id: user.id,
    properties: { plan, source: "web" }
  });

  await flush();
  return Response.json({ ok: true });
}
```

The browser sends `anonymous_id` along with the signup body — read it from your auth state or the SDK directly:

```ts
import { getAnonymousId } from "@millimetric/track";

await fetch("/api/signup", {
  method: "POST",
  body: JSON.stringify({ email, plan, anonymous_id: getAnonymousId() })
});
```

### Activation

"Activated" is product-specific — the moment a user does the thing your product is for. For Acme Notes, it's "created their first note that has at least 10 characters".

```ts
import { track } from "@millimetric/track";

export async function createNote(content: string) {
  // ...persist the note...
  if (content.length >= 10 && isFirstNote) {
    track("activated", { first_note_length: content.length });
  }
}
```

## Step 2 — verify in the dashboard

Quick smoke test before you query at scale:

```bash
curl -G "https://api.millimetric.ai/v1/query" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-05-17T00:00:00Z" \
  --data-urlencode "event=clicked_cta" \
  --data-urlencode "limit=10" | jq
```

You should see `clicked_cta` events with `properties` containing `location: "hero"`.

## Step 3 — funnel drop-off via /v1/stats

A simple count per step:

```bash
for ev in '$pageview' clicked_cta viewed_signup_form signup activated; do
  curl -sG "https://api.millimetric.ai/v1/stats" \
    -H "Authorization: Bearer $RK_KEY" \
    --data-urlencode "metric=uniques" \
    --data-urlencode "from=2026-05-01T00:00:00Z" \
    --data-urlencode "to=2026-05-17T00:00:00Z" \
    --data-urlencode "event=$ev" | jq -r ".metric, .rows[0].value"
done
```

→

```
uniques  4830     ← pageview
uniques  1144     ← clicked_cta
uniques   902     ← viewed_signup_form
uniques   314     ← signup
uniques   228     ← activated
```

Conversion rates: 24% (CTA), 79% (form view), 35% (signup), 73% (activation). End-to-end: 4.7%.

## Step 4 — the proper funnel query (ClickHouse)

The right way is a single window function over events, computing first-time-each-step per user:

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    minIf(timestamp, event_name = '$pageview')         AS s1,
    minIf(timestamp, event_name = 'clicked_cta')       AS s2,
    minIf(timestamp, event_name = 'viewed_signup_form') AS s3,
    minIf(timestamp, event_name = 'signup')            AS s4,
    minIf(timestamp, event_name = 'activated')         AS s5
  FROM events
  WHERE project_id = '...'
    AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
  GROUP BY anonymous_id
)
SELECT
  countIf(s1 IS NOT NULL)                                            AS landed,
  countIf(s2 IS NOT NULL AND s2 >= s1)                               AS clicked,
  countIf(s3 IS NOT NULL AND s3 >= s2)                               AS viewed_form,
  countIf(s4 IS NOT NULL AND s4 >= s3)                               AS signed_up,
  countIf(s5 IS NOT NULL AND s5 >= s4)                               AS activated,
  round(countIf(s5 IS NOT NULL AND s5 >= s4) * 100.0 / countIf(s1 IS NOT NULL), 2) AS pct
FROM steps;
```

The `s_n >= s_{n-1}` predicates enforce ordering (a user who *signed up* before they *viewed the form* doesn't count as a valid funnel — they came back through a deep link).

## Step 5 — break down by source

Where are the highest-converting visitors coming from?

```sql
SELECT
  argMin(source, timestamp)  AS first_source,
  argMin(medium, timestamp)  AS first_medium,
  countIf(event_name = '$pageview')  AS landed,
  countIf(event_name = 'signup')     AS signed_up,
  round(countIf(event_name = 'signup') * 100.0 /
        countIf(event_name = '$pageview'), 2) AS pct
FROM events
WHERE project_id = '...'
  AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
GROUP BY anonymous_id
HAVING landed > 0
GROUP BY first_source, first_medium
ORDER BY signed_up DESC;
```

You'll see Facebook *paid* on a different row than Facebook *social* — that's the [classifier](/core-concepts/attribution.md) doing its job.

For per-session attribution (better for revenue), query the `sessions` view directly. See [Marketing attribution dashboards](/recipes/marketing-attribution.md).

## Common pitfalls

* **Tracking on the click handler&#x20;*****and*****&#x20;in `useEffect`** — you'll double-count. Pick one, usually the click handler.
* **Not threading `anonymous_id` to the server** — your `signup` event lands without it, and the funnel breaks at step 4. Pass it through your auth body.
* **Tracking activation on a re-render** — gate it with `isFirstNote`. Otherwise the metric is "users who edited a note", which isn't the same.
* **Forgetting to call `/v1/identify`** — your post-signup events keep `user_id = NULL` and you lose per-user retention. Call it once on signup.

## See also

* [Events](/core-concepts/events.md), [Properties](/core-concepts/properties.md).
* [Anonymous → known users](/recipes/anonymous-to-known.md).
* [Marketing attribution dashboards](/recipes/marketing-attribution.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/recipes/signup-funnel.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
