Track Form Submissions
Form submissions are often the most important conversion event on a site, and they are also one of the most unreliable things to track via GTM’s built-in form trigger. This recipe covers native HTML forms, AJAX-submitted forms, and multi-step form wizards — and recommends the dataLayer approach for everything but the simplest case.
Why GTM’s built-in Form Submission trigger is unreliable
Section titled “Why GTM’s built-in Form Submission trigger is unreliable”GTM’s native Form Submission trigger works by listening for the browser’s submit event. This sounds fine but creates several problems in practice:
- AJAX forms never submit traditionally. They intercept the submit event, make an API call, and show a success message — all without a real form submission. GTM fires its trigger at the moment of interception, before you know if the submission succeeded.
- SPA forms may not use
<form>elements at all. React and Vue forms often handle submission state entirely in JavaScript with no native form element. - Tag fires before the user actually converts. If your form validation fails server-side, GTM already counted the conversion.
The dataLayer approach solves all of these: you push the event only after the server confirms a successful submission.
Native HTML forms
Section titled “Native HTML forms”For a simple server-rendered form (where the page reloads on success), the recommended pattern is to push to the dataLayer on the thank-you page or success page, not on the form page.
On your thank-you page, add this inline before the GTM snippet:
<script> window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'form_submission', form_name: 'contact_form', form_id: 'contact', lead_type: 'contact' });</script>This approach is 100% reliable: if the user reaches the thank-you page, the form submitted successfully.
AJAX forms (the correct approach)
Section titled “AJAX forms (the correct approach)”Most modern lead gen forms submit via AJAX. The submission handler looks something like this. Add the dataLayer push inside your success callback:
Push this only after the server confirms the submission succeeded.
async function handleFormSubmit(event) { event.preventDefault();
const formData = new FormData(event.target);
try { const response = await fetch('/api/contact', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Submission failed');
const data = await response.json();
// Only push AFTER confirmed success window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'form_submission', form_name: 'contact_form', form_id: event.target.id || 'contact', form_destination: '/api/contact', lead_id: data.leadId || undefined });
showSuccessMessage();
} catch (error) { // Do NOT push to dataLayer on error showErrorMessage(); }}
document.getElementById('contact-form').addEventListener('submit', handleFormSubmit);-
Add the dataLayer push to your form’s success callback as shown above.
-
Create a Custom Event Trigger
- Trigger type: Custom Event
- Event name:
form_submission
-
Create Data Layer Variables
For each parameter you push, create a Data Layer Variable:
- Name:
DLV - form_name→ Data Layer Variable name:form_name - Name:
DLV - form_id→ Data Layer Variable name:form_id - Name:
DLV - lead_id→ Data Layer Variable name:lead_id
- Name:
-
Create a GA4 Event Tag
- Tag type: Google Analytics: GA4 Event
- Event name:
generate_lead - Parameters:
form_name→{{DLV - form_name}}form_id→{{DLV - form_id}}lead_id→{{DLV - lead_id}}
- Trigger: the Custom Event trigger
-
Test in Preview Mode
Submit the form successfully. The
form_submissionevent should appear in the GTM Summary pane. Check the Variables tab for correct parameter values, then check Tags Fired.
GA4 - generate_lead
- Type
- Google Analytics: GA4 Event
- Trigger
- Custom Event - form_submission
- Variables
-
DLV - form_nameDLV - form_idDLV - lead_id
Multi-step forms
Section titled “Multi-step forms”Multi-step forms require tracking both the overall completion and progress through steps. Push a form_step event on each step advance, and form_submission only on final completion.
// Track each stepfunction trackFormStep(stepNumber, stepName) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'form_step', form_name: 'quote_request', step_number: stepNumber, step_name: stepName });}
// Call on each step advancetrackFormStep(1, 'contact_info');trackFormStep(2, 'project_details');trackFormStep(3, 'budget');
// On final submission successwindow.dataLayer.push({ event: 'form_submission', form_name: 'quote_request', total_steps: 3});Create a separate Custom Event trigger for form_step and a GA4 tag with step_number and step_name parameters. This lets you build a funnel analysis in GA4 to see where users drop off.
HubSpot, Marketo, and third-party forms
Section titled “HubSpot, Marketo, and third-party forms”Third-party form providers often embed forms in iframes or inject their own DOM. They usually provide JavaScript callbacks you can hook into:
// HubSpot form callbackwindow.addEventListener('message', function(event) { if (event.data.type === 'hsFormCallback' && event.data.eventName === 'onFormSubmit') { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'form_submission', form_name: 'hubspot_contact', form_id: event.data.id }); }});For Marketo, use the MktoForms2.whenSubmitted() callback. For Typeform, use their Embed SDK’s onSubmit handler.
Test it
Section titled “Test it”- Enable GTM Preview mode
- Fill out the form and submit it successfully
- The
form_submissionevent should appear in the Summary pane - Click it and verify parameter values in the Variables tab
- Verify Tags Fired shows your GA4 tag
- Check GA4 DebugView — the
generate_leadevent should appear within 15 seconds - Test the failure path: submit an invalid form and confirm NO dataLayer event fires
Common gotchas
Section titled “Common gotchas”Double firing on multi-step forms. If your multi-step form re-renders components on each step, event listeners may be attached multiple times. Use { once: true } on your event listener or track listener attachment state with a boolean flag.
Form embedded in iframe. If the form is in a cross-origin iframe, you cannot access it directly. Use the iframe’s postMessage callback. See the iFrame Tracking recipe.
SPA route change shows success message without submission. In SPAs, navigating back to a form might re-trigger a success state from cache. Guard your dataLayer push with a flag in sessionStorage.