Growth & Marketing AI

Turning a Form Submission Into a Real Person in PostHog

Result:

Every reader who submits a form with an email attached becomes a named person in your analytics tool, not another row of anonymous session data, so you can look up who asked for what and see everything else that same person did on your site, before and after.

Part of a series: this recipe assumes tracking who’s visiting your site is already wired up, and feeds directly into a dynamic ICP model, which needs real people, not anonymous sessions, to score.

Shortcut

Paste this into Claude Code, pointed at your own form’s submit handler.

Requires manual input

PostHog already initialized on your site behind person_profiles: 'identified_only'. If it isn’t yet, run tracking who’s visiting your site’s shortcut first.

My site already has PostHog initialized with person_profiles:
'identified_only'. Add identity resolution to my [form name] form:

1. After my API route confirms the submission succeeded, read the
   identifying field (e.g. email) from the form.
2. Call posthog.identify(distinctId, properties), using that value itself
   as the distinct_id, so a repeat submission with the same value merges
   into one profile instead of creating a new one.
3. Skip the identify() call entirely if that field is optional and was
   left empty.
4. Only fire this after both the field is present and the request has
   returned success, never on page load or before the response comes
   back.

Show me the diff against my existing submit handler.

Problem#

Tracking who’s visiting your site gets pageviews and clicks landing in PostHog, but with person_profiles: 'identified_only' set (the default this site uses to keep anonymous browsing cheap), none of those events attach to an actual person. Every visitor is just a randomly generated distinct_id with no name, no email, no way to ask “who submitted this form” or “what did this specific reader do before and after.” A real conversion, someone filling out a form and handing you an email address, is the moment that anonymous session should become a named person. Left unhandled, it doesn’t: the submission event lands, the identifying value sits in your database, and PostHog never learns the two are the same reader.

Pattern#

  1. Confirm your client is gated the same way this site’s is. person_profiles: 'identified_only' in the posthog.init() call (see the Pattern in tracking who’s visiting your site) means a profile only gets created once you explicitly ask for one. Anonymous events stay anonymous until then, by design, not by omission.
  2. Wait for the value, and for confirmation the submission succeeded. Read whatever identifying field your form collects (an email input, in this case) only after your API route returns success. A validation failure or a network error shouldn’t create a profile for a submission that never landed.
  3. Call posthog.identify(distinctId, properties) with the value itself as the distinct_id. When an email is the only identifying signal you have, pass it directly rather than generating a random id and stashing the email as a side property. That way a reader who submits the form twice, maybe weeks apart, from a different browser or after clearing cookies, merges into the same profile both times instead of forking into two.
  4. Skip the call entirely when the field is empty. If the identifying field is optional, as most contact and request forms are, gate the identify() call on the value being present. No identifying value means nothing to identify with; the event stays anonymous, which is correct, not a gap to patch.
  5. Confirm it in the Persons view, not just Activity. PostHog’s Activity tab shows the event landing either way. The real check is the Persons tab: search for the value you passed in and confirm a profile exists carrying it as a property, merged with whatever anonymous session led up to it.

Output#

This is the shipped pattern behind this site’s own request-a-recipe form: a successful submission with an email attached calls identify() right before the success state renders, gated on the server’s response, not on the client guessing.

Variations#

  • Use a generated id instead of raw email as the distinct_id. If you’d rather not use PII as the primary key, generate a stable id yourself, pass that to identify(), and set the email as a property instead. You lose the automatic cross-device merge on submission alone, but the identifying value stays out of your analytics tool’s id space.
  • Identify server-side instead of client-side. The client-side call in the Pattern above depends on the tracking script loading, so an ad blocker or a disabled-JS visitor never gets identified. PostHog’s server-side capture API can call the equivalent $identify event from your API route directly, right where you already validate the submission.
  • Reuse the same call at any other conversion point. A newsletter signup, a checkout, a login, any moment a visitor hands you an identifying value is the same shape: wait for success, call identify() with that value, skip it when the field’s empty. Once real people exist in PostHog instead of anonymous sessions, a dynamic ICP model has something to score segments against beyond a raw conversion count.

Nathaniel Stich writes Exit Velocities — recipes drawn from AI/growth systems shipped in production, not opinion pieces.

About the author