author Avatar

hero Image Blog

11 min read

The Truth About Contact Forms: They Suck (and My Astro Debugging Nightmare)

Thoughts-Process WebDevelopment

Published by

Author avatar

Abdul Rafay


Audio Content

Share this Blog Post:

WhatsAppTwitterFacebookLinkedIn

The Truth About Contact Forms: They Suck (and My Astro Debugging Nightmare) You heard the title right—I’m here to expose the reality of contact forms. And let’s be real: contact forms suck. I’m not just saying that to be edgy; I’m talking from experience. How many times have you filled out a form, hit submit, and then crickets? Or worse, you get a “Server Error” when all you wanted to do was ask a simple question? Yeah, contact forms are a menace, a spam magnet, and often a UI/UX disaster on mobile. I’ve built countless websites, tested every implementation under the sun, and I’m here to tell you exactly why they fail…and whether there’s any hope for making them less awful.

Before we dive in, you might be wondering: What even is a contact form? Why do they suck? Is there a way to make them better? Spoiler alert: the answer to the last one is a maybe with a heavy dose of “it depends.” But if you’re a developer, trying to build a functional, user-friendly contact form from scratch, this post is for you. If you’re happily using WordPress or Shopify… well, you’re probably living in a blissful alternate reality, and this post might just ruin your day. Consider yourself warned!

A Quick Note

If you’re using platforms like WordPress or Shopify, this might not apply to you. But if you’re a developer trying to build a functional, user-friendly contact form from scratch, this post is for you.

What is a Contact Form?

At its core, a contact form acts as a bridge between users and website owners. If a user has a question, feedback, or an issue, they can fill out the form to send a message. Typically, the form is connected to an email provider or a database, ensuring that messages reach the intended recipient.

In simple terms, a contact form helps users reach out without directly exposing an email address.

The Problem: My Astro Contact Form Debugging Vortex

Let me first explain the issue before diving into the solution. My personal site, built with Astro, includes a Connect with Me section with two contact forms—one on a dedicated page and another built into the homepage. Both forms share the same logic but have completely different UI designs. I thought I was being clever, making them reusable and all that… little did I know, I was about to enter a debugging vortex from which I might never return!

Since Astro supports both Server-Side Rendering (SSR) and Static Site Generation (SSG), I configured my project to use SSR. This allows me to integrate interactive UI components using React.

To make the contact form reusable, I structured it as follows:

  • ContactForm.tsx → The UI component.
  • useContactForm.tsx → A custom hook handling form submission.
  • submit.ts → The API route handling the request.

The idea was simple: the form UI would send data to the hook, which would then call the API to submit the form. If I ever wanted to switch from Web3Forms to another service, I could simply change the API call without modifying the rest of my code.

Here’s the sequence diagram explaining my approach:

Everything seemed perfect—reusable, modular, and scalable. But in reality, things weren’t that simple. I spent an entire day debugging without any progress.

The Unexpected Error

The form submissions were reaching my email, yet the UI kept displaying “Server Error”. Even the server logs showed errors, despite the network requests returning a 200 status. This was weird—the API was clearly working, but the UI still showed an error. WHAT?! A 200 status code but an error on the front end? I felt like I was in the Twilight Zone. 🤯

I spent days debugging. I checked the Network tab in Chrome DevTools, expecting to find an issue with my request. But everything looked fine—status 200, no CORS errors, no missing headers. I even tested the API separately in Postman, but the issue persisted.

At this point, I was convinced that my code wasn’t the problem. Maybe Web3Forms itself was causing the issue?

Then, after some modifications, a new error appeared:

Web3Forms Request Failed: TypeError: fetch failed
    at node:internal/deps/undici/undici:13392:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Module.POST (D:/Astro-Portfolio-Blog/src/pages/api/submit.ts:27:24)
    ...
    [cause]: ConnectTimeoutError: Connect Timeout Error (attempted address: api.web3forms.com:443, timeout: 10000ms)

This was a timeout error, meaning the request was taking too long to connect. But Web3Forms’ official documentation didn’t mention any timeout limits. So why was this happening? Was Web3Forms secretly powered by hamsters on a wheel? 🤔

To fix this, I decided to handle timeouts manually in my API by setting an explicit timeout and catching errors properly. Here’s the final version of my submit.ts file:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);

try {
  const response = await fetch("https://api.web3forms.com/submit", {
    method: "POST",
    body: submitFormData,
    signal: controller.signal,
  });

  clearTimeout(timeout);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data?.message || "Web3Forms submission failed.");
  }

  return new Response(
    JSON.stringify({ success: true, message: "Message sent successfully!" }),
    { status: 200, headers: { "Content-Type": "application/json" } }
  );
} catch (error) {
  if (error.name === "AbortError") {
    console.error("Web3Forms Request Timed Out");
    return new Response(
      JSON.stringify({ success: false, message: "Request timed out." }),
      { status: 504, headers: { "Content-Type": "application/json" } }
    );
  }
  console.error("Web3Forms Request Failed:", error);
  return new Response(
    JSON.stringify({ success: false, message: "Failed to contact Web3Forms." }),
    { status: 500, headers: { "Content-Type": "application/json" } }
  );
}

The New Issue

After implementing this, I expected everything to finally work. But no. A new problem arose. Even though the timeout issue was handled, sometimes the request would still fail randomly. Seriously? I was starting to think contact forms were cursed. 👿

So now, the question is: Is this an issue with Web3Forms, Astro’s API routes, or something else entirely? At this point, I was half-tempted to just ditch the whole thing and tell people to contact me via carrier pigeon.

This journey has been full of surprises, and I’ll go into how I finally solved it in the next section.

New Service: The Hunt for a Less Terrible Option

At this point, I was done. Debugging, trying new things, rewriting code, and debugging again—I needed a solution. So, I started searching for alternatives, and oh boy, I was in for another few days of torture. And no, I’m not talking about the code itself—I’m talking about the sheer number of services available for something as simple as a contact form. It’s like everyone and their grandma has a contact form service these days!

From my experience, there are two ways to get a contact form working:

  1. Connecting the form to a database (which felt like overkill for a simple contact form)
  2. Using a service that forwards form submissions to your email

I was using Web3Forms, which is a great service if you have a static HTML, CSS, or JavaScript-based website. But the moment you introduce React into the mix—things start breaking. And since I was using React within Astro, this became a huge headache.

The Hunt for an Alternative

So, I went on the hunt for a new service. There are quite a few out there, but let me tell you—some of them have insane pricing. Some don’t even have a free tier! As a solo developer, I’m not about to drop $10/month just to get a contact form working. That’s like half my coffee budget! ☕️

Here are some of the services I looked into:

  1. Formspree – A solid service, but no free tier. If you’re running a site with high traffic, this might be worth it, but for a solo dev like me? Not so much. I tried their solution, but just like Web3Forms, it didn’t work smoothly with React. Plus, their documentation felt like it was written in hieroglyphics.

    As you can see, the pricing is steep for a small personal site. If you have a larger user base, this might work, but for me, it just wasn’t feasible.

  2. FormBold – Another solid option, but again, no free tier. Their pricing structure is almost identical to Formspree, which makes it just as inaccessible for small personal projects.

I also briefly considered X, Y, and Z, but their privacy policies made my skin crawl. I’m not about to sell my users’ data just so they can send me a message!

Many of these services are great, but from a cost perspective, they just don’t make sense for someone like me. Spending $10/month on a contact form is simply too much, especially when the free plans don’t offer useful features.

Meanwhile, Web3Forms does have a free plan—but unfortunately, after all my testing, I just couldn’t get it to work properly with React and Astro. It was a classic case of “too good to be true.”

The Solution: Embrace the Hack (Maybe?)

Okay, full disclosure: what I ended up doing isn’t exactly going to win any awards for code elegance. But after days of wrestling with Web3Forms, React, and the mysteries of Astro’s API routes, I was desperate. I needed something that worked, even if it meant sacrificing a bit of code purity.

So, I did what any sane (or perhaps insane) developer would do: I reverted to a plain Astro component with inline <script> tags. Yes, you read that right. I know, I know… it’s like going back to the Stone Age of web development. But hear me out!

See, Web3Forms seemed to only play nice when it was dealing with static HTML and JavaScript. The moment React got involved, things went haywire. I suspect it’s something to do with how React handles the form lifecycle or maybe some weird timing issue. Whatever the reason, the only way I could get Web3Forms to reliably send emails without throwing random errors was to bypass all the fancy React hooks and API routes and just… make it static.

[Show a screenshot of the code here, maybe with a circle around the <script> tags and a “Don’t judge me!” caption]

Here’s the code I wrote for the contact form:

---
import BaseHead from "@astro/base/BaseHead.astro";
import Header from "@astro/header/Header.astro";
import Footer from "@astro/footer/Footer.astro";
import authorConfig from "@config/siteConfig/info.json";
import { featureFlags } from "@config/featureFlag/featureFlag.json";
import SpeedInsights from "@vercel/speed-insights/astro";

try {
  if (!featureFlags.showContact) {
    return Astro.redirect("/access-denied");
  }
} catch (error) {
  return Astro.redirect("/404");
}
---

<html lang="en">
  <head>
    <BaseHead
      title={`Contact Me | ${authorConfig.SiteName}`}
      description="Contact me and get in touch with me."
    />
    <SpeedInsights />
  </head>
  <body>
    <Header />
    <main>
      <h1 class="text-5xl text-center font-bold mb-6">Let's Collaborate</h1>

      <section class="flex justify-center items-center bg-[--accent-dark] px-4">
        <div
          class="w-full max-w-5xl bg-[--gray-gradient] p-10 rounded-2xl border border-[--gray] mt-6"
        >
          <form
            class="space-y-4"
            id="form"
            action="https://api.web3forms.com/submit"
          >
            <input type="hidden" name="access_key" value="29b18f36-e5a9-43e0-b896-79ccb8509f17" />

            <div>
              <label class="block text-[--text-light] mb-1" for="name">Your Name</label>
              <input
                type="text"
                id="name"
                name="name"
                class="w-full p-3 rounded-lg bg-[--gray-dark] text-[--text-light] border border-[--gray] focus:outline-none focus:ring-2 focus:ring-[--accent]"
                placeholder="John Doe"
                required
              />
            </div>

            <div>
              <label class="block text-[--text-light] mb-1" for="email">Email</label>
              <input
                type="email"
                id="email"
                name="email"
                class="w-full p-3 rounded-lg bg-[--gray-dark] text-[--text-light] border border-[--gray] focus:outline-none focus:ring-2 focus:ring-[--accent]"
                placeholder="you@example.com"
                required
              />
            </div>

            <div>
              <label class="block text-[--text-light] mb-1" for="message">Message</label>
              <textarea
                id="message"
                name="message"
                class="w-full p-3 rounded-lg bg-[--gray-dark] text-[--text-light] border border-[--gray] focus:outline-none focus:ring-2 focus:ring-[--accent]"
                rows="4"
                placeholder="Write your message..."
                required
              ></textarea>
            </div>

            <div class="h-captcha" data-captcha="true"></div>

            <button
              type="submit"
              class="w-full p-3 bg-[--accent] text-[--text-light] rounded-lg hover:bg-opacity-90 transition"
            >
              Send Message
            </button>

            <div id="result" class="hidden text-center p-3 mt-4 rounded-lg"></div>
          </form>
        </div>
      </section>
    </main>

    <Footer class="mt-auto" />

    <script is:inline>
      document.getElementById("form").addEventListener("submit", function (e) {
        const hcaptchaResponse = document.querySelector('textarea[name="h-captcha-response"]');
        if (!hcaptchaResponse || hcaptchaResponse.value === "") {
          e.preventDefault();
          alert("Please complete the hCaptcha.");
        }
      });
    </script>

    <script is:inline src="https://web3forms.com/client/script.js" async defer></script>

    <script is:inline>
      const form = document.getElementById("form");
      const result = document.getElementById("result");

      form.addEventListener("submit", function (e) {
        e.preventDefault();

        const formData = new FormData(form);
        const object = Object.fromEntries(formData);
        const json = JSON.stringify(object);

        result.className = "block bg-gray-600 text-white text-center p-3 mt-4 rounded-lg";
        result.innerHTML = "Please wait...";

        fetch("https://api.web3forms.com/submit", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Accept: "application/json",
          },
          body: json,
        })
          .then(async (response) => {
            let json = await response.json();
            if (response.status == 200) {
              result.className = "block bg-green-600 text-white text-center p-3 mt-4 rounded-lg";
              result.innerHTML = json.message;
            } else {
              result.className = "block bg-red-600 text-white text-center p-3 mt-4 rounded-lg";
              result.innerHTML = json.message;
            }
          })
          .catch((error) => {
            console.error(error);
            result.className = "block bg-red-600 text-white text-center p-3 mt-4 rounded-lg";
            result.innerHTML = "Something went wrong!";
          })
          .then(() => {
            form.reset();
            setTimeout(() => {
              result.classList.add("opacity-0", "transition-opacity", "duration-500");
              setTimeout(() => {
                result.classList.add("hidden");
                result.classList.remove("opacity-0");
              }, 500);
            }, 3000);
          });
      });
    </script>
  </body>
</html>

I created a contact section with a form, where two key elements play a crucial role:

  1. The #result element, which displays feedback messages (because who doesn’t love instant gratification?).
  2. The hCaptcha field, because spam bots are the bane of my existence.

By utilizing inline <script> tags within the Astro component, I ensured that these scripts are only executed when needed. While this may not be the most elegant approach, it was the only way to make Web3Forms work seamlessly in a static environment.

So, is it perfect? Absolutely not. Is it a bit of a hack? You bet. But does it (mostly) work? Yes! And sometimes, in the world of web development, “mostly working” is a victory in itself.

Final Thoughts: The Contact Form Struggle is Real

Building a contact form might seem like a simple task, but when you factor in React, Astro, and the sheer number of third-party services with their limitations, things can quickly spiral into frustration. Seriously, why is something so basic so darn hard?

I started with Web3Forms, which worked great for static sites but failed when React entered the equation. My search for an alternative led me to services like Formspree and FormBold, but their pricing made them unviable for a solo developer. Spending $10/month on something as basic as a contact form just didn’t make sense, especially when the free plans weren’t offering much value. I’d rather spend that money on coffee! ☕

This whole process made me realize something: sometimes, the simplest solutions are either broken or overpriced. If you’re a solo developer like me, you’re either stuck with limited free options or forced to pay more than what feels reasonable for your needs. It’s a constant battle between functionality and budget. 😩

At the end of the day, I needed a cost-effective, React-friendly contact form solution, and while my journey wasn’t smooth, it was definitely an eye-opener about the challenges of integrating third-party services in a modern web stack.

For now, the hunt continues. 🚀

So, have you wrestled with contact forms in Astro or React? What’s your preferred (least terrible) solution? Let me know in the comments! I’m always looking for new (or at least slightly less painful) ways to solve this age-old problem.

Until then, peace out, nerds. 👓

Comments