A Squarespace client called me with a bug that made no sense. Their form submissions were firing an event into the data layer — but every field came through blank. Name, email, phone: all empty, on every single submission. The form worked perfectly for them; the confirmation showed; leads landed in their inbox. But the tracking captured nothing but ghosts.
I’ve seen this exact ghost before, and it’s one of the sneakiest problems in the whole platform. It’s not that the data isn’t there. It’s that Squarespace’s React form clears the input values before the success state finishes painting — so by the time you detect the submission and try to read the fields, React has already wiped them. You’re reading an empty form and pushing empty data.
Once you understand that, the fix is obvious: don’t read the fields at submit time. Capture them as the user types. Here’s the full solution.
Why Squarespace forms are so hard to track
Squarespace form blocks are React components, and that breaks every conventional approach:
- No page reload, no thank-you page. The form submits in place and swaps to a success message. There’s no navigation to hang a pageview on.
- No reliable submit event. GTM’s native Form Submission trigger doesn’t fire dependably on these React forms.
- The success signal is a CSS class. On success, the
<form class="react-form-contents">gainsreact-form-contents--submitted, and a sibling post-submit block gainsreact-form-post-submit--submitted. That’s the only clean signal you get. - And the killer: React blanks the inputs before the success class lands. This is what produces the empty-data ghost. If your listener waits for the success state and then reads the form, the values are already gone.
So the job has two halves: detect success by watching for that class change, and already have the data before success wipes it.
The core idea: cache as they type
The trick that makes this reliable is caching the form values on every keystroke, so the data is safely stored before React can clear it. The listener snapshots the form on input, on change, on pointerdown of the submit button (a last-chance grab), and on submit — and it guards the cache so a mid-submit blanking can’t overwrite good data with empties. When the success class finally appears, it pushes the cached data, not whatever’s left in the DOM.
On top of that, it reads the fields intelligently — splitting first/last name, lowercasing email, and converting the phone number to E.164 using the country dial code from the select — and it optionally SHA-256 hashes the PII into a separate event, ready for Meta CAPI or Google Ads Enhanced Conversions.
The full listener
<script>
(function () {
'use strict';
/* ------------------------------------------------------------------
* Squarespace (React form block) → dataLayer submit listener
* AnalyticsRush.com — Tuhin (Md Sarwar Hossain)
*
* Success signal: <form class="react-form-contents"> gains the class
* "react-form-contents--submitted", and the sibling post-submit block
* gains "react-form-post-submit--submitted".
*
* Values are cached on every keystroke because React clears the inputs
* before the success state finishes painting.
* ------------------------------------------------------------------ */
var CONFIG = {
eventName: 'sqsp_form_submit',
hashedEventName: 'sqsp_form_submit_hashed', // set to null to disable
defaultDialCode: '44', // used if country select is unreadable
debug: false // true = console logs
};
if (window.__sqspFormDLListener) return;
window.__sqspFormDLListener = true;
window.dataLayer = window.dataLayer || [];
var cache = new WeakMap();
var fired = new WeakSet();
function log() {
if (CONFIG.debug && window.console) console.log.apply(console, ['[sqsp-form]'].concat([].slice.call(arguments)));
}
function val(el) {
return el && el.value ? String(el.value).trim() : '';
}
/* ---------------------------- field reading ---------------------------- */
function readForm(form) {
var d = {
first_name: '', last_name: '', email: '',
phone_country: '', phone_raw: '', phone_e164: '', extra: {}
};
// Name (fieldset.fields.name → two text inputs)
var nameFs = form.querySelector('fieldset.fields.name, .form-item.fields.name');
if (nameFs) {
var nameInputs = nameFs.querySelectorAll('input');
d.first_name = val(nameInputs[0]);
d.last_name = val(nameInputs[1]);
} else {
d.first_name = val(form.querySelector('input[id$="-first"], input[name*="fname" i]'));
d.last_name = val(form.querySelector('input[id$="-last"], input[name*="lname" i]'));
}
// Email
d.email = val(form.querySelector('input[type="email"], .form-item.email input, .field.email input')).toLowerCase();
// Phone (fieldset.fields.phone → country select + tel input)
var phoneFs = form.querySelector('fieldset.fields.phone, .form-item.fields.phone');
if (phoneFs) {
var sel = phoneFs.querySelector('select');
var tel = phoneFs.querySelector('input');
if (sel) {
d.phone_country = sel.value || '';
var optText = sel.selectedOptions && sel.selectedOptions[0] ? sel.selectedOptions[0].text : '';
var dial = (optText.match(/\+(\d{1,4})/) || [])[1] || (String(sel.value).match(/\+?(\d{1,4})/) || [])[1];
d.phone_dial = dial || CONFIG.defaultDialCode;
} else {
d.phone_dial = CONFIG.defaultDialCode;
}
d.phone_raw = val(tel);
} else {
d.phone_raw = val(form.querySelector('input[type="tel"]'));
d.phone_dial = CONFIG.defaultDialCode;
}
d.phone_e164 = toE164(d.phone_raw, d.phone_dial);
// Any other fields, keyed by label text
form.querySelectorAll('.form-item').forEach(function (item) {
if (item.matches('.fields.name, .fields.phone, .email, .field.email')) return;
var label = item.querySelector('.title, label');
var input = item.querySelector('input, textarea, select');
if (!label || !input) return;
var key = label.textContent.replace(/\(required\)/i, '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_');
if (key && val(input)) d.extra[key] = val(input);
});
return d;
}
function toE164(raw, dial) {
if (!raw) return '';
var digits = String(raw).replace(/[^\d+]/g, '');
if (digits.charAt(0) === '+') return digits;
digits = digits.replace(/\D/g, '');
if (!digits) return '';
digits = digits.replace(/^0+/, ''); // strip national trunk prefix before adding country code
return '+' + String(dial || CONFIG.defaultDialCode) + digits;
}
/* ---------------------------- caching ---------------------------- */
function snapshot(form) {
var data = readForm(form);
// don't overwrite good data with a mid-submit blanking
var prev = cache.get(form);
if (prev && !data.email && !data.first_name && !data.phone_raw) return;
cache.set(form, data);
log('cached', data);
}
document.addEventListener('input', function (e) {
var form = e.target.closest && e.target.closest('form.react-form-contents');
if (form) snapshot(form);
}, true);
document.addEventListener('change', function (e) {
var form = e.target.closest && e.target.closest('form.react-form-contents');
if (form) snapshot(form);
}, true);
document.addEventListener('pointerdown', function (e) {
var btn = e.target.closest && e.target.closest('.form-button-wrapper button, button[type="submit"], .sqs-block-button-element');
if (!btn) return;
var form = btn.closest('form.react-form-contents');
if (form) snapshot(form);
}, true);
document.addEventListener('submit', function (e) {
if (e.target && e.target.matches && e.target.matches('form.react-form-contents')) snapshot(e.target);
}, true);
/* ---------------------------- success detection ---------------------------- */
function formIsSubmitted(form) {
if (form.classList.contains('react-form-contents--submitted')) return true;
var wrapper = form.closest('.form-wrapper');
if (wrapper && wrapper.querySelector('.react-form-post-submit--submitted')) return true;
return false;
}
function push(form) {
if (fired.has(form)) return;
fired.add(form);
var d = cache.get(form) || readForm(form);
var block = form.closest('.sqs-block-form');
var formId = block ? (block.getAttribute('data-website-component-id') || block.id || '') : '';
var heading = block ? block.querySelector('.form-block-title, h1, h2') : null;
var payload = {
event: CONFIG.eventName,
form_id: formId,
form_name: heading ? heading.textContent.trim() : 'Squarespace Form',
form_location: location.pathname,
user_data: {
first_name: d.first_name,
last_name: d.last_name,
email: d.email,
phone_number: d.phone_e164,
country: d.phone_country
},
form_fields: d.extra
};
log('push', payload);
window.dataLayer.push(payload);
if (CONFIG.hashedEventName) pushHashed(payload);
}
/* ---------------------------- optional SHA-256 ---------------------------- */
function sha256(str) {
if (!str || !window.crypto || !window.crypto.subtle) return Promise.resolve('');
var buf = new TextEncoder().encode(String(str).trim().toLowerCase());
return window.crypto.subtle.digest('SHA-256', buf).then(function (hash) {
return Array.prototype.map.call(new Uint8Array(hash), function (b) {
return ('00' + b.toString(16)).slice(-2);
}).join('');
}).catch(function () { return ''; });
}
function pushHashed(payload) {
var u = payload.user_data;
Promise.all([sha256(u.email), sha256(u.phone_number), sha256(u.first_name), sha256(u.last_name)])
.then(function (h) {
window.dataLayer.push({
event: CONFIG.hashedEventName,
form_id: payload.form_id,
user_data_hashed: {
sha256_email_address: h[0],
sha256_phone_number: h[1],
sha256_first_name: h[2],
sha256_last_name: h[3]
}
});
});
}
/* ---------------------------- observer ---------------------------- */
var mo = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === 'attributes' && m.target.matches && m.target.matches('form.react-form-contents')) {
if (formIsSubmitted(m.target)) push(m.target);
continue;
}
if (m.type === 'attributes' && m.target.classList && m.target.classList.contains('react-form-post-submit--submitted')) {
var wrap = m.target.closest('.form-wrapper');
var f = wrap && wrap.querySelector('form.react-form-contents');
if (f) push(f);
continue;
}
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (n.nodeType !== 1) continue;
if (n.matches && n.matches('form.react-form-contents') && formIsSubmitted(n)) push(n);
if (n.querySelectorAll) {
n.querySelectorAll('form.react-form-contents').forEach(function (ff) {
if (formIsSubmitted(ff)) push(ff);
});
}
}
}
});
mo.observe(document.documentElement, {
childList: true, subtree: true, attributes: true, attributeFilter: ['class']
});
// catch a form already in the submitted state on load
document.querySelectorAll('form.react-form-contents').forEach(function (f) {
if (formIsSubmitted(f)) push(f);
});
log('listener ready');
})();
</script>
What each part is doing
- The caching listeners (
input,change,pointerdown,submit) snapshot the form as the user interacts, storing it in aWeakMapkeyed by the form element. Thesnapshotguard refuses to overwrite good cached data with an empty read — that’s the specific defense against React’s blanking. readFormpulls first/last name, lowercased email, and the phone number, and grabs any other fields keyed by their label text so custom fields come through automatically.toE164normalizes the phone: it reads the dial code from the country select, strips the national trunk prefix (the leading 0), and prepends the country code — because a properly formatted phone is worth far more for match quality than a raw local number.- The
MutationObserverwatches for the--submittedclass on the form or the post-submit block. That’s the reliable success signal, and it’s why this works without a real submit event. fired(aWeakSet) ensures each form pushes only once, so a flurry of class mutations doesn’t fire five duplicate events.pushHashedSHA-256 hashes the email, phone, and name into a separate event — which brings us to the most important part.
Two events, and why that matters: PII
The listener deliberately pushes two events: sqsp_form_submit with the raw user_data, and sqsp_form_submit_hashed with SHA-256 hashed values. That separation is intentional, because how you route this data matters more than capturing it.
- Send the hashed event to your ad platforms. The
sqsp_form_submit_hashedevent is what feeds Meta CAPI and Google Ads Enhanced Conversions — hashed email and phone are exactly what they need for matching, and hashing before anything leaves keeps you on the right side of privacy. Even better, pass this into your server-side container and forward it from there. - Keep raw PII out of GA4. Use the plain event only for the non-identifying parts — form name, form ID, location, and the fact that a lead happened. Google’s terms prohibit sending personal data to Analytics, so never map the raw email or phone into a GA4 tag.
- Respect consent. None of this — raw or hashed — should fire for a user who hasn’t consented. Gate it behind your consent setup.
The GTM setup
- Custom HTML tag with the listener, firing on All Pages (early), so the caching listeners are attached before anyone starts typing.
- Custom Event triggers for
sqsp_form_submitandsqsp_form_submit_hashed. - Data Layer Variables —
form_name,form_id, and for the ad-platform side, theuser_data_hashed.sha256_*values. - GA4 event tag on
sqsp_form_submit(non-PII fields only), marked as a Key Event. - Meta CAPI / Enhanced Conversions fed from the hashed event, ideally server-side.
Mistakes to avoid
- Reading the fields at submit time. This is the Squarespace trap. React has already blanked them. Cache as the user types — everything else depends on this.
- Using GTM’s native Form Submission trigger. It doesn’t fire reliably on these React forms. Watch for the success class instead.
- Sending raw PII to GA4. Names, emails, and phones must not go into Analytics. That’s what the separate hashed event is for.
- Skipping phone normalization. A raw local number matches poorly. Convert to E.164 so the country code is right.
- Firing duplicate events. The class flips several times during the success animation; the
firedguard is what keeps you to one push per form. - Ignoring consent. Hashing isn’t a consent workaround. Gate the whole thing.
Final thoughts
Squarespace forms feel untrackable until you spot the real problem: it was never that the data wasn’t there, it’s that React wipes it a beat before you look. Capture the values as the user types, watch the DOM for the success class, and you get every submission cleanly — name, email, and a properly formatted phone. Then handle what you captured with care: hashed and consent-gated for the ad platforms, non-identifying for GA4. That’s the difference between a form event full of ghosts and one that actually drives your attribution.