Uncategorized

Tracking Squarespace Form Submissions in GTM: The React Trap Nobody Warns You About

August 3, 202610 min read

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"> gains react-form-contents--submitted, and a sibling post-submit block gains react-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 a WeakMap keyed by the form element. The snapshot guard refuses to overwrite good cached data with an empty read — that’s the specific defense against React’s blanking.
  • readForm pulls 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.
  • toE164 normalizes 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 MutationObserver watches for the --submitted class 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 (a WeakSet) ensures each form pushes only once, so a flurry of class mutations doesn’t fire five duplicate events.
  • pushHashed SHA-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_hashed event 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

  1. Custom HTML tag with the listener, firing on All Pages (early), so the caching listeners are attached before anyone starts typing.
  2. Custom Event triggers for sqsp_form_submit and sqsp_form_submit_hashed.
  3. Data Layer Variablesform_name, form_id, and for the ad-platform side, the user_data_hashed.sha256_* values.
  4. GA4 event tag on sqsp_form_submit (non-PII fields only), marked as a Key Event.
  5. 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 fired guard 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.

Leave a Reply

Your email address will not be published. Required fields are marked *