DEV Community

Brightcode
Brightcode

Posted on Originally published at withbrightcode.com

Your contact form says "Thank you". Did the lead arrive? How we test website forms end to end

By the Brightcode team, a small web agency in Delhi NCR. Reviewed by Gaurav Khokkar, Founder.

The most expensive bug we look for on a client site doesn't throw an error. The form submits, the visitor sees a green "Thanks, we'll be in touch", and the lead goes nowhere. It lands in a spam folder, or in the inbox of someone who left last year, or it never leaves the server at all.

Nobody reports it, because the person it fails for has already gone off to call a competitor. The owner only notices months later, when enquiries look "a bit slow".

So for every site we build or take over, we treat the lead path as a system that needs tests, the same as a checkout. Here's what that looks like.

Step 1: List every lead path, not just the contact page

A business site rarely has one form. We write them all down before touching code:

  • Contact and "get a quote" forms
  • Popup or footer forms (often from a different plugin than the main one)
  • Phone links (tel:)
  • WhatsApp buttons
  • Email links (mailto:)
  • Booking widgets, newsletter sign-ups, careers forms

For each one, note where the submission is supposed to end up (which inbox, CRM or sheet) and who is supposed to answer it. That second column is where the surprises are. More than once, the honest answer has been "nobody knows".

Check the links while you're at it. On our own site, the WhatsApp button used to be a JavaScript click handler with no real link behind it. It worked for most visitors, but it isn't a link to a crawler, to some in-app browsers, or to anyone with a script blocked. Now it's a plain <a href="https://wa.me/...">. Boring, and it always works.

Step 2: Stop relying on mail() and the host's default sender

The classic setup is a form plugin that calls PHP's mail() on shared hosting and sends as wordpress@yourdomain.com. That message usually has no proper authentication for your domain, and receiving servers are much stricter than they used to be. Google's sender guidelines say that since February 2024 all senders to Gmail accounts must set up SPF or DKIM, and bulk senders need SPF, DKIM and DMARC.

A small business form sends a few emails a day, nowhere near bulk. It still makes sense to do all three:

  • Send through an authenticated SMTP or transactional email service, from an address on your own domain
  • Publish SPF and DKIM records for that service
  • Add a DMARC record, even if it starts at p=none while you watch the reports

You can check what's published in a minute:

dig +short TXT example.com | grep spf
dig +short TXT _dmarc.example.com
# DKIM needs the selector your email provider gave you:
dig +short TXT selector1._domainkey.example.com
Enter fullscreen mode Exit fullscreen mode

Then send a test submission to a Gmail address and use "Show original". You want to see PASS next to SPF, DKIM and DMARC.

Step 3: Save the lead before you send the email

Treat the email as a notification. It shouldn't be the only place a lead lives. If SMTP is down for an hour, or a credential expires, the lead should still exist somewhere.

Our rule: write the submission to storage first, then notify. Here's the shape of it in a Next.js route handler, with a honeypot field for basic bot filtering:

// app/api/lead/route.js
import nodemailer from "nodemailer";
import { saveLead } from "@/lib/leads"; // your DB, sheet or CRM call

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

export async function POST(req) {
  const data = await req.json();

  // Honeypot: a hidden "company_website" field real users never fill in
  if (data.company_website) return Response.json({ ok: true });

  if (!data.name || !data.phone) {
    return Response.json({ ok: false, error: "Name and phone are required" }, { status: 400 });
  }

  const lead = await saveLead({ ...data, receivedAt: new Date().toISOString() });

  try {
    await transporter.sendMail({
      from: '"Website enquiry" <leads@example.com>',
      to: process.env.LEADS_INBOX,
      replyTo: data.email || undefined,
      subject: `New enquiry from ${data.name}`,
      text: `${data.name}\n${data.phone}\n${data.email || "-"}\n\n${data.message || ""}`,
    });
  } catch (err) {
    console.error("Lead saved but email failed", lead.id, err); // alert on this
  }

  return Response.json({ ok: true, id: lead.id });
}
Enter fullscreen mode Exit fullscreen mode

The honeypot returns a fake success so bots don't learn anything. Validation runs on the server, because anyone can skip client-side checks. And an email failure is logged loudly instead of being swallowed, since the lead is already safe.

Step 4: Fire the analytics event only on a real success

A lot of sites send their "lead" event when the button is clicked. That counts double-clicks, validation errors and bot hits as leads, and the numbers in analytics drift away from the numbers in the inbox.

GA4 has a recommended event for this, generate_lead. We send it only after the server says the lead was saved:

const res = await fetch("/api/lead", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(formData),
});
const body = await res.json();

if (res.ok && body.ok) {
  window.gtag?.("event", "generate_lead", { form_id: "contact_main" });
  showThankYou();
} else {
  showError(body.error || "Something went wrong. Please call or WhatsApp us.");
}
Enter fullscreen mode Exit fullscreen mode

Two more things that bite people. If you use Google Tag Manager, check the data layer variable names match exactly, because a typo there sends events with empty parameters and no error. We had this on a site we run, and the fix was a one-line rename in GTM. And remember to mark the event as a key event in GA4, or it never shows up as a lead in reports.

Step 5: Test it like code, then keep testing it

Testing once at launch won't cover you. Forms break later, when a plugin updates, a caching layer starts serving a stale nonce, a CAPTCHA key is tied to the old domain after a move, or someone changes the inbox.

We use a Playwright test that submits the real form and checks the response, not just the thank-you text:

// tests/lead-form.spec.js
import { test, expect } from "@playwright/test";

test("contact form saves a lead", async ({ page }) => {
  await page.goto("/contact");
  await page.fill('input[name="name"]', "Form Test (automated)");
  await page.fill('input[name="phone"]', "0000000000");
  await page.fill('textarea[name="message"]', `monitor ${Date.now()}`);

  const [response] = await Promise.all([
    page.waitForResponse((r) => r.url().includes("/api/lead")),
    page.click('button[type="submit"]'),
  ]);

  expect(response.status()).toBe(200);
  expect((await response.json()).ok).toBe(true);
  await expect(page.getByText(/thank/i)).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Run it in CI on every deploy, and on a schedule against production (daily is plenty for most sites). Tag the test leads clearly so the sales team can ignore them and filter them out of analytics. If you can, add one more check that the test lead actually reached the inbox or CRM, because that's the part the browser test can't see.

Once a month, a human should still do one test on a real phone: fill in the form, tap the phone link, tap WhatsApp. Automated tests catch regressions. A person catches "this form asks for eleven fields on mobile and nobody finishes it".

The short version

  • List every lead path and who owns it
  • Send from your own domain with SPF, DKIM and DMARC
  • Save first, notify second, and alert when the notification fails
  • Fire generate_lead only on a confirmed save
  • Test the form in CI and on a schedule, and do a real-phone check monthly

For the non-code side of this (offer, proof, tracking and the thank-you step), our checklist on what a conversion-ready landing page needs before you turn on ads covers what marketing and sales should sign off. If the forms live on WordPress, plugin updates are the usual reason they break, which is why regular security and maintenance matters for leads, not just for safety.

How do you monitor forms on sites you've handed over? We'd like to hear what's worked for you in the comments.

Written by the team at Brightcode, a Delhi NCR agency that builds business websites and keeps them working with website maintenance.

Top comments (0)