How to Add a Contact Form to a Static Website

TL;DR β€” Skip to what you need
In This Guide
  1. The static site form problem
  2. Plain HTML (no framework)
  3. React / Vite
  4. Next.js (App Router + Server Actions)
  5. Vue 3 (Composition API)
  6. Spam protection
  7. Backend options compared

The Static Site Problem

You built a beautiful static site β€” maybe Astro, Next.js, or plain HTML deployed to Netlify or GitHub Pages. Everything works except one thing: forms need a server to receive submissions and send emails.

The classic solution was to spin up a backend. That means a server, a database, an email provider, rate limiting, spam filtering, and ops work β€” all just to receive "Hi, I want to hire you" messages.

The modern solution: use a form backend service that handles all of that. You point your form's action (or fetch) at an endpoint, and submissions get emailed to you, stored in a dashboard, and filtered for spam.

What you'll need: A free LaunchQ account (no credit card). Copy your form endpoint URL from the dashboard β€” it looks like https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID.

Plain HTML (No Framework)

The simplest possible integration. Works on any host β€” Netlify, Vercel, GitHub Pages, S3, Cloudflare Pages, or even a local file. No JavaScript required.

HTML
<!-- Drop this anywhere in your HTML -->
<form
  action="https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID"
  method="POST"
>
  <!-- Honeypot β€” bots fill this, humans don't see it -->
  <input type="text" name="_honeypot" style="display:none" tabindex="-1" autocomplete="off">

  <!-- Optional: redirect after submit -->
  <input type="hidden" name="_redirect" value="https://yoursite.com/thank-you">

  <label for="name">Your Name</label>
  <input type="text" id="name" name="name" placeholder="Jane Doe" required>

  <label for="email">Email Address</label>
  <input type="email" id="email" name="email" placeholder="jane@example.com" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <button type="submit">Send Message</button>
</form>

πŸ’‘ How it works: The form POSTs directly to LaunchQ's endpoint. LaunchQ emails you instantly, stores the submission in your dashboard, and runs AI spam detection β€” then redirects the visitor to your thank-you page. No JavaScript, no API key in the frontend.

Replace YOUR_FORM_ID with the ID from your LaunchQ dashboard, and optionally set the _redirect to a thank-you page on your site.

React / Vite

With React you get more control: a loading state, error handling, and a success message without a page redirect. This works with Create React App, Vite, and any React-based framework.

React (JSX)
import { useState } from 'react';

const FORM_ID = 'YOUR_FORM_ID'; // from FormBox dashboard
const ENDPOINT = `https://formbox.gibby-workspace.com/submit/${FORM_ID}`;

export default function ContactForm() {
  const [status, setStatus] = useState('idle'); // idle | loading | success | error

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('loading');

    const formData = new FormData(e.target);

    try {
      const res = await fetch(ENDPOINT, {
        method: 'POST',
        headers: { 'Accept': 'application/json' },
        body: formData,
      });

      if (res.ok) {
        setStatus('success');
        e.target.reset();
      } else {
        setStatus('error');
      }
    } catch {
      setStatus('error');
    }
  }

  if (status === 'success') {
    return (
      <div className="success-message">
        <h3>βœ… Message sent!</h3>
        <p>Thanks β€” I'll get back to you within 24 hours.</p>
        <button onClick={() => setStatus('idle')}>Send another</button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* Honeypot */}
      <input type="text" name="_honeypot" style={{ display: 'none' }} tabIndex={-1} />

      <div>
        <label htmlFor="name">Name</label>
        <input type="text" id="name" name="name" required />
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required />
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows={5} required />
      </div>

      {status === 'error' && (
        <p style={{ color: 'red' }}>Something went wrong. Please try again.</p>
      )}

      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending…' : 'Send Message'}
      </button>
    </form>
  );
}

πŸ’‘ Pass Accept: application/json in headers so LaunchQ returns JSON instead of doing a browser redirect. That way your React component can handle the success/error state itself.

Next.js (App Router)

Next.js 14+ App Router supports two approaches: a classic client-side fetch, or the newer Server Actions pattern for zero-client-JS forms.

Option A β€” Client Component (recommended for most cases)

app/contact/ContactForm.tsx
'use client';

import { useState, FormEvent } from 'react';

const FORMBOX_ENDPOINT =
  `https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID`;

export default function ContactForm() {
  const [state, setState] = useState<'idle'|'loading'|'done'|'error'>('idle');

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setState('loading');

    const res = await fetch(FORMBOX_ENDPOINT, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: new FormData(e.currentTarget),
    });

    setState(res.ok ? 'done' : 'error');
  }

  if (state === 'done')
    return <p>βœ… Message received β€” I'll reply within 24 h.</p>;

  return (
    <form onSubmit={onSubmit} noValidate>
      <input type="text" name="_honeypot" aria-hidden style={{ display: 'none' }} />
      <input type="text" name="name" placeholder="Name" required />
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" rows={5} required />
      {state === 'error' && <p role="alert">Send failed. Try again?</p>}
      <button disabled={state === 'loading'}>
        {state === 'loading' ? 'Sending…' : 'Send'}
      </button>
    </form>
  );
}

Option B β€” Server Action (no client JS)

Server Actions let you handle the form on the server side, then revalidate or redirect. Useful if you want zero client-side JavaScript for the form itself.

app/contact/actions.ts
'use server';

import { redirect } from 'next/navigation';

export async function submitContact(formData: FormData) {
  // Forward directly to LaunchQ from the server
  const res = await fetch(
    `https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID`,
    {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: formData,
    }
  );

  if (!res.ok) {
    // You can use `useFormState` for error handling
    throw new Error('Submission failed');
  }

  redirect('/thank-you');
}
app/contact/page.tsx
import { submitContact } from './actions';

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input type="text" name="name" placeholder="Name" required />
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" rows={5} required />
      <button type="submit">Send Message</button>
    </form>
  );
}

⚠️ Note on Server Actions: When calling LaunchQ from a Server Action, the request comes from your Next.js server, not the user's browser. This means LaunchQ gets the server IP, not the visitor IP. For spam detection purposes, Option A (client-side fetch) gives more accurate results.

Vue 3 (Composition API)

Works the same way β€” a fetch on submit, reactive status variable.

Vue 3 (SFC)
<script setup>
import { ref } from 'vue';

const ENDPOINT = 'https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID';
const status = ref('idle'); // idle | loading | success | error

async function handleSubmit(e) {
  status.value = 'loading';
  const formData = new FormData(e.target);

  try {
    const res = await fetch(ENDPOINT, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: formData,
    });
    status.value = res.ok ? 'success' : 'error';
    if (res.ok) e.target.reset();
  } catch {
    status.value = 'error';
  }
}
</script>

<template>
  <div v-if="status === 'success'" class="success">
    βœ… Message sent! I'll reply soon.
  </div>

  <form v-else @submit.prevent="handleSubmit">
    <input type="text" name="_honeypot" aria-hidden style="display:none" />
    <input type="text" name="name" placeholder="Name" required />
    <input type="email" name="email" placeholder="Email" required />
    <textarea name="message" placeholder="Message" rows="5" required></textarea>
    <p v-if="status === 'error'" class="error">Something went wrong. Please try again.</p>
    <button :disabled="status === 'loading'">
      {{ status === 'loading' ? 'Sending…' : 'Send Message' }}
    </button>
  </form>
</template>

Spam Protection

Contact forms attract spam bots within hours of going live. Here's a layered defence strategy from basic to advanced.

Layer 1 β€” Honeypot Field (zero friction, catches most bots)

Add a hidden field that humans never fill in. Bots auto-fill everything. If the field has a value β†’ reject. This stops ~90% of naive bots with zero impact on real users.

HTML
<!-- Honeypot: visible to bots, hidden to humans -->
<div style="position:absolute; left:-5000px;" aria-hidden="true">
  <input type="text" name="_honeypot" tabindex="-1" autocomplete="off">
</div>

LaunchQ automatically checks for _honeypot in every submission β€” no extra code needed on your end.

Layer 2 β€” Rate Limiting (built into LaunchQ)

LaunchQ enforces per-form rate limits out of the box. Aggressive bots that send dozens of requests get blocked automatically. You see the block count in your dashboard.

Layer 3 β€” AI Spam Detection (LaunchQ exclusive)

LaunchQ runs every submission through an AI content classifier that scores it on a 0-100 spam scale. Submissions above the threshold are flagged automatically β€” they still appear in your dashboard (in case of false positives) but won't trigger email notifications.

You can tune the threshold in your form settings:

Layer 4 β€” Google reCAPTCHA (only if you need it)

For the most sensitive forms (login, billing enquiries), add reCAPTCHA v3 (invisible, no checkbox). Only use this if layers 1-3 aren't enough β€” CAPTCHA adds friction.

HTML + reCAPTCHA v3
<!-- 1. Load the reCAPTCHA script -->
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>

<!-- 2. In your form submit handler -->
<script>
document.querySelector('form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'contact' });
  // Append token to form data
  const fd = new FormData(e.target);
  fd.append('g-recaptcha-response', token);
  await fetch('https://formbox.gibby-workspace.com/submit/YOUR_FORM_ID', {
    method: 'POST',
    headers: { Accept: 'application/json' },
    body: fd,
  });
});
</script>

Form Backend Options Compared

There are several services in this space. Here's how they stack up on the things that matter for indie makers and small teams.

Feature LaunchQ Formspree Netlify Forms Basin
Free tier βœ… 100 subs/mo βœ… 50/mo βœ… 100/mo βœ… 100/mo
AI spam detection βœ… Yes ❌ No ❌ No ❌ No
Submission dashboard βœ… Full analytics βœ… Basic βœ… Basic βœ… Basic
Webhook integrations βœ… Yes βœ… Paid ❌ No βœ… Paid
CSV export βœ… Free βœ… Paid ❌ No βœ… Free
Works with any host βœ… Yes βœ… Yes ❌ Netlify only βœ… Yes
Email digests βœ… Daily + weekly ❌ No ❌ No ❌ No
Paid plan starts at $29/mo $16/mo $29/mo $12/mo

Bottom line: If you're not locked into Netlify, LaunchQ gives you the most features on the free tier and the best spam protection at any price point.

Add your first contact form in 2 minutes

Free tier includes 100 submissions/month, AI spam detection, and a full analytics dashboard. No credit card required.

Get Started Free β†’

Already have an account? Sign in β†’

Related Articles
Comparison
Best Formspree & Typeform Alternatives in 2026
Reference
LaunchQ API Documentation