Send a form to your email

Send a form to your email6 min read

An EmailJS alternative that keeps keys off the client

Move a contact form off EmailJS: why browser-side email keys are a risk, what changes in your code, and what you give up, such as the auto-reply.

Preet SutharFounder, Ikiform

EmailJS sends email from the browser, so your service id, template id and public key ship in your page's JavaScript for anyone to copy. A form backend needs no key in the browser: the form posts to a URL, and the backend filters spam, stores the message and emails you. You lose EmailJS's auto-reply to the visitor, which you'd send from your own email tool instead.

What's different here

EmailJS is popular for a good reason: it sends real email from a static site with no server. To do that, the browser calls EmailJS directly with three values: a service id, a template id and a public key. They're in your bundle, readable in the page source or the network tab.

EmailJS's own FAQ says someone who copies them can only send your templates with your content, not arbitrary email. That holds for the template's fixed text. But the caller fills in the template's variables, so the copied keys can send your template with any name, message and links in it, as often as your quota allows. And if a template's To is a variable, as an auto-reply to the visitor's address is, the caller also picks the recipient.

Who can send through a browser email key, and through a form endpointbrowser email sdkform endpointyour pageservice, templateand public keyanyone's scriptcopies the threevaluesyour templatewith their text, toits Toyour pagea form id, no secretIkiformspam score, ratelimit, alloweddomainsyour recipientsonlyset in thedashboardview sourcesendpostemailWho can send through a browser email key, and through a form endpointbrowser email sdkform endpointyour pageservice, template and public keyanyone's scriptcopies the three valuesyour templatewith their text, to its Toyour pagea form id, no secretIkiformspam score, rate limit, alloweddomainsyour recipients onlyset in the dashboardview sourcesendpostemail
  1. With a browser email SDK, the page holds everything needed to send. A script elsewhere can copy the ids and key and send your template with its own text, up to your quota.
  2. If the template's recipient comes from a variable, the copied keys also choose who receives it.
  3. With a form endpoint, the page holds only a form id. Posts are filtered for spam and rate limited, and email goes only to the recipients you set in the dashboard.
A form id isn't a secret either. The difference is what a stranger can do with it: submit your form, not send your email.

EmailJS has settings that narrow this, such as limiting which domains may use your key and adding a captcha to a template. Check which ones your plan includes and turn them on if you stay. The comparison below is about the design, not a verdict on the service:

ItemEmailJSIkiform
What the browser holdsService id, template id, public keyA form id
Who decides the recipientThe template, which may read a variableOnly your dashboard settings
Spam filteringCaptcha per template, if you add itHoneypot, spam scoring and rate limits on every form, plus an optional human check
Your messages afterwardsIn your inboxIn the dashboard, CSV export and the API, as well as your inbox
Email to the visitorYes, with an auto-reply templateNo
Your own email designYes, templates in their editorNo, a fixed layout with the subject New submission: <form name>

If the auto-reply or a branded email is the point, EmailJS, or your own server with an email API, fits better. If you want the message in your inbox, safely, and kept somewhere, a form backend fits.

Move the form

A typical EmailJS form in React, based on their contact form tutorial:

ContactForm.tsx, before
import emailjs from "@emailjs/browser";
import { type FormEvent, useRef, useState } from "react";

export function ContactForm() {
  const form = useRef<HTMLFormElement>(null);
  const [status, setStatus] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!form.current) return;
    try {
      await emailjs.sendForm("service_abc123", "template_xyz789", form.current, {
        publicKey: "aBcDeFgHiJkLmNoPq",
      });
      setStatus("Message sent.");
    } catch {
      setStatus("Couldn't send your message.");
    }
  }

  return (
    <form ref={form} onSubmit={onSubmit}>
      <input name="user_name" required />
      <input name="user_email" type="email" required />
      <textarea name="message" required />
      <button>Send</button>
      <p role="status">{status}</p>
    </form>
  );
}

The same form posting to Ikiform with submit() from @ikiform/sdk:

ContactForm.tsx, after
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setFields({});
    setError("");
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) {
      setStatus("sent");
      return;
    }
    setFields(result.error.fields ?? {});
    setError(result.error.code === "network_error" ? "You're offline. Try again." : result.error.message);
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Message sent. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="user_name">Name</label>
      <input id="user_name" name="user_name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      {fields.email ? <p role="alert">{fields.email[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
      {fields.message ? <p role="alert">{fields.message[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
    </form>
  );
}
  • @emailjs/browser and the three ids go. Uninstall the package, and delete the EmailJS template or service once the new form works, so cached copies of your page stop sending.
  • user_email becomes email. EmailJS doesn't care about names, but Ikiform uses the field called email as the reply-to. The other names can stay, because notifications list fields as they arrive.
  • submit() never throws. It returns the error envelope, so field errors from the form's definition show under their input.
  • The honeypot is new. EmailJS had none; this hidden input catches the simplest bots with no setup.
  • Add your domain under Allowed domains, so posts from other sites are marked as spam.
Using the human check? Set the form's Spam check to Ikiform, then call challenge(FORM_ID) before submit and pass the token as turnstileToken. The React guide shows it.

If you used the auto-reply

Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. A sending service you control from a server, such as Resend or Postmark, can send it on each submission.created webhook, with the visitor's address from the trusted payload rather than from the browser. The multiple recipients guide has a handler you can adapt.

Ask whether you need it. A contact form's thank-you message on the page tells the visitor it worked, and a real reply from you an hour later beats a template.

Check it works

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Search your built site for emailjs and your old service id. Nothing should match.
  3. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
  4. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.
  5. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.

If something goes wrong

ProblemWhat to do
Reply still goes to IkiformThe field is still user_email. Rename it email, or one of email, email_address, your_email, reply_to, contact_email.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.
403 challenge_requiredThe form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off.
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.