Skip to content

Form Tracking

Form submissions are often the most important conversion event on a site — and one of the most commonly broken tracking implementations. The GTM built-in Form Submission trigger works fine for simple static forms, but most modern forms are AJAX-based, framework-rendered, or multi-step, and the built-in trigger won’t catch any of them.

This guide covers every approach, from the simplest to the most complex.

The built-in Form Submission trigger (and its limits)

Section titled “The built-in Form Submission trigger (and its limits)”

GTM’s Form Submission trigger listens for the submit event on HTML form elements. For traditional forms that post to a URL and navigate away, it works reliably.

Configure it like this:

  • Trigger type: Form Submission
  • Check Validation: On — waits for the browser to validate required fields before the trigger fires
  • Wait for Tags: On — prevents page navigation until your tags have had time to fire
  • Condition: Form ID equals your-form-id
Section titled “The dataLayer approach (recommended for all modern forms)”

The correct pattern for any modern form is a dataLayer.push() call in your form submission handler. This gives you complete control over when the event fires, what data it includes, and whether the form was actually valid.

// React example (using a form library like react-hook-form or native onSubmit)
function ContactForm() {
const handleSubmit = async (formData) => {
const response = await submitToAPI(formData);
if (response.ok) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_submission',
form_id: 'contact_form',
form_name: 'Contact Us',
form_location: 'contact_page',
success: true
});
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
// Vanilla JS
document.getElementById('contact-form').addEventListener('submit', function(e) {
e.preventDefault();
fetch('/api/contact', {
method: 'POST',
body: new FormData(this)
}).then(function(response) {
if (response.ok) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_submission',
form_id: 'contact_form',
form_name: 'Contact Us',
form_location: document.body.dataset.pageType || 'unknown'
});
}
});
});

In GTM, create a Custom Event trigger for form_submission and attach it to your GA4 Event tag:

Tag Configuration

GA4 - Event - Form Submission

Type
Google Analytics: GA4 Event
Trigger
CE - form_submission
Variables
DLV - form_idDLV - form_nameDLV - form_location

Push the success event after the server confirms the submission, not on the click. This ensures you’re tracking actual conversions, not attempts.

For forms spread across multiple steps, track each step as a separate event:

// Step 1 complete
window.dataLayer.push({
event: 'form_step_complete',
form_id: 'checkout',
form_step: 1,
form_step_name: 'personal_details'
});
// Step 2 complete
window.dataLayer.push({
event: 'form_step_complete',
form_id: 'checkout',
form_step: 2,
form_step_name: 'address'
});
// Final submission
window.dataLayer.push({
event: 'form_submission',
form_id: 'checkout',
form_step: 3,
form_step_name: 'payment',
total_steps: 3
});

This lets you build a funnel in GA4 Explorations showing exactly where users drop off.

Form abandonment — a user starts filling in a form but never submits — is valuable to track. The approach uses focus events combined with a visibility check on page unload:

(function() {
var form = document.getElementById('signup-form');
if (!form) return;
var formStarted = false;
var formSubmitted = false;
// Mark as started on first field interaction
form.addEventListener('focusin', function() {
if (!formStarted) {
formStarted = true;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_start',
form_id: 'signup',
form_name: 'Newsletter Signup'
});
}
});
// Mark as submitted on successful submission
form.addEventListener('submit', function() {
formSubmitted = true;
});
// On page hide: if started but not submitted, record abandonment
window.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden' && formStarted && !formSubmitted) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_abandonment',
form_id: 'signup',
form_name: 'Newsletter Signup'
});
}
});
})();

Detecting AJAX form submissions without code access

Section titled “Detecting AJAX form submissions without code access”

If you cannot modify the site (legacy CMS, third-party form builder), you can detect AJAX submissions by monitoring XMLHttpRequest calls with a Custom HTML tag:

<script>
(function() {
var originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._gtmUrl = url;
return originalOpen.apply(this, arguments);
};
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function() {
var xhr = this;
this.addEventListener('load', function() {
if (xhr._gtmUrl && xhr._gtmUrl.indexOf('/contact') !== -1 && xhr.status === 200) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_submission',
form_id: 'contact_ajax',
detection_method: 'xhr_intercept'
});
}
});
return originalSend.apply(this, arguments);
};
})();
</script>

Validation errors tell you where users struggle. Track them when the user attempts to submit but validation fails:

form.addEventListener('submit', function(e) {
var invalidFields = Array.prototype.slice.call(form.querySelectorAll(':invalid'));
if (invalidFields.length > 0) {
var fieldNames = invalidFields.map(function(field) {
return field.name || field.id || field.type;
});
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'form_validation_error',
form_id: 'registration',
error_fields: fieldNames.join(','),
error_count: invalidFields.length
});
}
});

Using a click trigger on the submit button tracks click attempts, not successful submissions. A user can click submit, see validation errors, and never submit. You want conversion-quality data, not click data.

Pushing the event before the AJAX call completes

Section titled “Pushing the event before the AJAX call completes”

The success event must fire after you receive a successful response from your API — not on submit, not on click. Anything earlier counts attempts, not conversions.

Using the built-in trigger for React or Vue forms

Section titled “Using the built-in trigger for React or Vue forms”

React and Vue manage form submission at the component level, often preventing the native submit event from reaching the DOM in a way GTM can intercept. Always use dataLayer.push() inside your component’s submit handler.

Capturing form field values that contain PII

Section titled “Capturing form field values that contain PII”

Never push email addresses, full names, phone numbers, or any personally identifiable information to the dataLayer without explicit consent architecture. Push form metadata — not the data users entered.