embedio

Framework guides

Add a contact form to a React app

A single-page React app has nowhere to send a form unless you build and host an API. This component posts straight to embedio instead, handles the loading, success and error states, and shows server-side validation messages next to the button.

1. Create the form in embedio

Sign up (the Free plan includes 500 submissions a month and needs no card), create a form, and copy its key. It looks like frm_ followed by ten characters. Everywhere below, replace frm_your_form_key with yours.

The form key is meant to be public: it only lets people submit to that one form, never read anything. It's safe in client-side code and in your repository.

2. The component

Drop this into your project. It sends the fields as JSON, so embedio answers with JSON and nothing navigates away.

src/ContactForm.tsx
import { useState, type FormEvent } from "react";

const ENDPOINT = "https://api.tryembedio.com/f/frm_your_form_key";

type Status =
  | { state: "idle" | "sending" | "sent" }
  | { state: "error"; message: string };

export function ContactForm() {
  const [status, setStatus] = useState<Status>({ state: "idle" });

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setStatus({ state: "sending" });

    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "application/json" },
        body: JSON.stringify(Object.fromEntries(new FormData(form))),
      });

      if (response.ok) {
        form.reset();
        setStatus({ state: "sent" });
        return;
      }

      if (response.status === 429) {
        setStatus({ state: "error", message: "Too many attempts. Try again in a minute." });
        return;
      }

      const body = await response.json().catch(() => ({}));
      const message =
        body.error === "validation_failed"
          ? body.fields.map((f: { message: string }) => f.message).join(" ")
          : "Something went wrong. Please try again.";
      setStatus({ state: "error", message });
    } catch {
      setStatus({ state: "error", message: "Network error. Check your connection and retry." });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      {/* Honeypot: hidden from people, filled in by bots */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />

      <button type="submit" disabled={status.state === "sending"}>
        {status.state === "sending" ? "Sending…" : "Send"}
      </button>

      <p role="status" aria-live="polite">
        {status.state === "sent" && "Thanks! Your message is on its way."}
        {status.state === "error" && status.message}
      </p>
    </form>
  );
}

With Vite you can move the key into .env as VITE_EMBEDIO_FORM=frm_... and build the URL from import.meta.env.VITE_EMBEDIO_FORM. It's not a secret, so this is only for switching forms between environments.

3. After a successful submit

If you set a custom message or redirect for the form in the dashboard, the success response carries it as message and redirectUrl. To use them, read the body on success and either show body.message or call window.location.assign(body.redirectUrl).

Spam and validation

  • Honeypot: a hidden _gotcha field. People never see it; bots that fill it get a normal-looking success and nothing is stored.
  • Double clicks: an identical submission seconds after the first is dropped instead of stored twice.
  • Trust level: each form has a relaxed, balanced or strict setting for bursts and repeated content. Flagged submissions are kept aside for review, not deleted.
  • Server-side rules: validations such as required, email or max length run on embedio, so they hold even if someone skips your front end.

Getting notified

Every submission shows up in your dashboard straight away. To also get an email for each one, add an Email connection to the form; that works on every plan, including Free. Slack, Discord, Zapier, webhooks and the other connections come with any paid plan, from $9/mo.

Errors you might see

With a JSON request, every failure comes back as a status code plus { "error": "..." }. A plain HTML post gets a readable error page instead.

StatuserrorWhat it means
404form_not_foundThe form key is wrong or the form was deleted.
422validation_failedA validation rule failed; fields lists { field, message } for each one.
402monthly_limit_reachedThe account used its submissions for the month.
413file_too_largeAn uploaded file is over 10MB.
400invalid_file_typeThe file type isn't accepted (images, PDF, Office documents, TXT and CSV are).
429More than 20 submissions a minute from one IP address. Wait and retry.

The full list, including module-specific errors, is in the API reference.