JavaScript contact form that sends email

Formgong team ·

A JavaScript contact form still needs somewhere to send the POST. Call fetch, send the fields, and read the JSON reply. Formgong accepts that call with no server of yours. You do not need a library or an API route. On the free plan, Telegram is immediate and email is a daily digest.

Copy this JavaScript form

JavaScript
<form id="fg-contact" action="https://formgong.com/submit" method="POST">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <input type="hidden" name="_lang" value="en">
  <label>Name <input type="text" name="name" autocomplete="name" placeholder="Your name" required></label>
  <label>Email <input type="email" name="email" autocomplete="email" placeholder="you@example.com" required></label>
  <label>Message <textarea name="message" placeholder="How can we help?" required></textarea></label>
  <div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
  <label>Leave this field empty<input type="text" name="botcheck" tabindex="-1" autocomplete="off"></label>
</div>
  <button type="submit">Send</button>
  <p role="status"></p>
</form>
<script>
  const form = document.querySelector("#fg-contact");
  const status = form.querySelector("[role=status]");
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button");
    button.disabled = true;
    const fields = Object.fromEntries(new FormData(form));
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "application/json" },
        body: JSON.stringify(fields),
      });
      const result = await response.json();
      status.textContent = result.message || (result.success ? "Sent. Thank you!" : "Could not send. Please try again.");
      if (result.success) form.reset();
    } catch {
      status.textContent = "Connection failed. Please try again.";
    } finally {
      button.disabled = false;
    }
  });
</script>
Send FormData instead of JSON
Send FormData instead of JSON
<script>
  const form = document.querySelector("#fg-contact");
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const status = form.querySelector("[role=status]");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      status.textContent = result.message || (result.success ? "Sent. Thank you!" : "Could not send. Please try again.");
      if (!result.success) return;
      form.reset();
    } catch {
      status.textContent = "Connection failed. Please try again.";
    }
  });
</script>
Only the fetch call, if the form is already on the page
Only the fetch call, if the form is already on the page
async function onSubmit(event) {
  event.preventDefault();
  const fields = Object.fromEntries(new FormData(event.currentTarget));
  const response = await fetch("https://formgong.com/submit", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    // _lang: language of Formgong messages and the autoreply (multilingual site: document.documentElement.lang).
    body: JSON.stringify({ _lang: "en", ...fields, access_key: "fk_your_access_key" }),
  });
  const result = await response.json();
  if (!result.success) throw new Error(result.message || "Could not send. Please try again.");
  return result.message; // localized "Sent. Thank you!"
}

Common problems

On the free plan, email is a daily digest to one address. Telegram is instant on every plan. Pro and Business email each lead.

CORS

The submit endpoint allows any browser origin. A fetch from your site, including localhost, is allowed.

On Pro you can limit the form to your domains. If that list is set and the page host is not on it, the lead is refused.

Double submit

Each successful POST is a new lead. The sample disables the button while the request runs, so a second click does not send twice.

If you write your own script, disable the button before you call fetch, and turn it back on when the call ends.

CSP connect-src

If the page sets a Content-Security-Policy, fetch is blocked unless connect-src includes https://formgong.com.

A plain form POST is a navigation. If you also set form-action, include the same host there.

Localhost testing

Open the page on localhost and send a test. The request still goes to formgong.com, and the lead shows in the inbox.

Free accepts that origin. On Pro, add localhost to the allow list if you turned the list on.

How forms work in JavaScript

People search for a JavaScript contact form that sends email, not for a fetch contact form. fetch is only the browser call that does the POST. You do not need EmailJS, Axios, or a server route.

Send an Accept header of application/json, or the endpoint answers with a redirect, which is what a plain form wants. A failed fetch throws. A rejected lead comes back with success set to false and a message you can show.

Set it up in JavaScript

  1. Create a form in Formgong and copy the access key.
  2. Paste the snippet above. It is a form plus a short script: async and await, JSON, and a try catch for network errors.
  3. Keep the honeypot field empty and off screen. If a bot fills it, the lead is stored as spam and is not delivered.
  4. The script disables the button while the request runs, then shows the message from the response. On success it clears the form.
  5. Open the live page and send a test. Check the inbox, then connect Telegram if you want a phone alert.

Ways to send a JavaScript contact form

OptionWhat you runWhat you get
mailto:Nothing on a server. The mail app opens.A message only if the visitor sends it. Easy to break on a phone.
Your own API routeA server, SMTP, and a secret.Email you control. You also own spam, downtime, and the code.
EmailJSTheir script in the page.Email through their service. This row does not quote a price. It was not on their pricing page in this check.
FormspreeNone. The browser posts to Formspree.Email and a dashboard. Free is 50 submissions a month. Personal is 15 dollars a month.
FormgongNone. fetch posts to Formgong.Telegram at once. Email is a daily digest on Free. 300 submissions a month, stored in the EU.

Formspree prices come from formspree.io/plans on 6 Oct 2026. Formgong limits come from the product code. EmailJS ranks for this query. Its price was not re-checked that day. Web3Forms.com returned HTTP 403, so it is not in this table.

Component package: npm install @formgong/core (npm: @formgong/core)

Add to an existing project: npx formgong init (npm: formgong)

fetch, a library, or your own server?

Use this page when the form is vanilla JavaScript, a small widget, or a modal. The visitor stays on the page.

Use a plain HTML form when JavaScript may be off. The form above still has an action, so it works if the script never runs.

Write your own server only when the lead must change data in your app. EmailJS sends through their script. Formgong is one POST.

Questions and answers

Can JavaScript send a contact form to email without a server?

The browser cannot speak SMTP. It can POST the fields to a form backend. That backend sends the mail. You do not run a server for this form.

Should I send FormData or JSON?

JSON fits text fields. FormData fits when you already have the form element, and when you send files. Both reach the same inbox. Do not set Content-Type yourself on FormData.

Why do I get a redirect instead of JSON?

A plain form POST returns a redirect. To get JSON, send an Accept header of application/json. Then read success and the message.

How should I handle errors?

A failed fetch throws. That is a network error. A rejected lead returns JSON with success set to false. Show that message, and keep what the visitor typed.

Do I need EmailJS or another library?

No. fetch is built into the browser. A library is fine if you already use one. Send the same fields and the same headers.

Start free