Formulario de contacto en React que envía email sin backend

Formgong team ·

Envía FormData con fetch al endpoint de Formgong y pega tu clave de acceso pública. Formgong filtra el spam y entrega el aviso por email y por Telegram. Telegram es al momento. En el plan gratis el email es un resumen diario. No montas servidor, SMTP ni claves secretas. Pega el componente de abajo.

Crea un formulario, copia la clave de acceso y sustituye fk_your_access_key en el componente. La clave es pública. Su sitio es este archivo.

Vite o Create React App
import { useState } from "react";

// Pega tu clave: sustituye fk_your_access_key por la clave de tu formulario de Formgong.
const ACCESS_KEY = "fk_your_access_key";

export default function ContactForm() {
  const [status, setStatus] = useState("idle");
  const [notice, setNotice] = useState("");

  async function onSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
    const body = new FormData(form);
    body.set("access_key", ACCESS_KEY);
    body.set("_lang", "es");
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body,
      });
      const result = await response.json();
      if (!response.ok || !result.success) {
        setStatus("error");
        setNotice(result.message || "No se pudo enviar. Inténtalo de nuevo.");
        return;
      }
      form.reset();
      setStatus("success");
      setNotice(result.message || "Enviado. ¡Gracias!");
    } catch {
      setStatus("error");
      setNotice("Error de conexión. Inténtalo de nuevo.");
    }
  }

  const sending = status === "sending";
  return (
    <form onSubmit={onSubmit}>
      <label>{"Nombre"} <input name="name" autoComplete="name" placeholder={"Tu nombre"} required /></label>
      <label>{"Correo electrónico"} <input name="email" type="email" autoComplete="email" placeholder={"tu@ejemplo.com"} required /></label>
      <label>{"Mensaje"} <textarea name="message" placeholder={"¿En qué podemos ayudarte?"} required /></label>
      <div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={sending}>{sending ? "Enviando…" : "Enviar"}</button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Otras variantes de React

Vite o Create React App

El componente de arriba es esta variante. Guárdalo como src/ContactForm.jsx y muéstralo desde App. Sin paquete extra.

Vite o Create React App
// src/App.jsx — Vite or Create React App
import ContactForm from "./ContactForm";

export default function App() {
  return <ContactForm />;
}
Next.js App Router

Añade la directiva de cliente al inicio del archivo. La página que pinta el formulario puede seguir siendo un Server Component.

Next.js App Router
"use client";

import { useState } from "react";

// Pega tu clave: sustituye fk_your_access_key por la clave de tu formulario de Formgong.
const ACCESS_KEY = "fk_your_access_key";

export default function ContactForm() {
  const [status, setStatus] = useState("idle");
  const [notice, setNotice] = useState("");

  async function onSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
    const body = new FormData(form);
    body.set("access_key", ACCESS_KEY);
    body.set("_lang", "es");
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body,
      });
      const result = await response.json();
      if (!response.ok || !result.success) {
        setStatus("error");
        setNotice(result.message || "No se pudo enviar. Inténtalo de nuevo.");
        return;
      }
      form.reset();
      setStatus("success");
      setNotice(result.message || "Enviado. ¡Gracias!");
    } catch {
      setStatus("error");
      setNotice("Error de conexión. Inténtalo de nuevo.");
    }
  }

  const sending = status === "sending";
  return (
    <form onSubmit={onSubmit}>
      <label>{"Nombre"} <input name="name" autoComplete="name" placeholder={"Tu nombre"} required /></label>
      <label>{"Correo electrónico"} <input name="email" type="email" autoComplete="email" placeholder={"tu@ejemplo.com"} required /></label>
      <label>{"Mensaje"} <textarea name="message" placeholder={"¿En qué podemos ayudarte?"} required /></label>
      <div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={sending}>{sending ? "Enviando…" : "Enviar"}</button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

// app/contact/page.jsx — this file can stay a Server Component.
import ContactForm from "./ContactForm";

export default function Page() {
  return <ContactForm />;
}
React Hook Form

Instala react-hook-form. Deja ahí la validación. El envío sigue siendo FormData hacia Formgong.

React Hook Form
import { useState } from "react";
import { useForm } from "react-hook-form";

// Pega tu clave: sustituye fk_your_access_key por la clave de tu formulario de Formgong.
const ACCESS_KEY = "fk_your_access_key";

export default function ContactForm() {
  const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm();
  const [notice, setNotice] = useState("");
  const [failed, setFailed] = useState(false);

  async function onSubmit(values) {
    const body = new FormData();
    body.set("access_key", ACCESS_KEY);
    body.set("_lang", "es");
    body.set("name", values.name);
    body.set("email", values.email);
    body.set("message", values.message);
    body.set("botcheck", values.botcheck || "");
    setFailed(false);
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body,
      });
      const result = await response.json();
      if (!response.ok || !result.success) {
        setFailed(true);
        setNotice(result.message || "No se pudo enviar. Inténtalo de nuevo.");
        return;
      }
      reset();
      setNotice(result.message || "Enviado. ¡Gracias!");
    } catch {
      setFailed(true);
      setNotice("Error de conexión. Inténtalo de nuevo.");
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>{"Nombre" <input {...register("name", { required: "Obligatorio" })} aria-invalid={errors.name ? true : undefined} autoComplete="name" /></label>
      {errors.name && <span>{errors.name.message}</span>}
      <label>{"Correo electrónico" <input type="email" {...register("email", { required: "Obligatorio" })} aria-invalid={errors.email ? true : undefined} autoComplete="email" /></label>
      {errors.email && <span>{errors.email.message}</span>}
      <label>{"Mensaje" <textarea {...register("message", { required: "Obligatorio" })} aria-invalid={errors.message ? true : undefined} /></label>
      {errors.message && <span>{errors.message.message}</span>}
      <div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input {...register("botcheck")} tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={isSubmitting}>{isSubmitting ? "Enviando…" : "Enviar"}</button>
      {failed ? <p role="alert">{notice}</p> : <p role="status">{notice}</p>}
    </form>
  );
}
TypeScript

El mismo componente, con un tipo para el estado y para la respuesta JSON. Guárdalo como ContactForm.tsx.

TypeScript
import { useState, type FormEvent } from "react";

// Pega tu clave: sustituye fk_your_access_key por la clave de tu formulario de Formgong.
const ACCESS_KEY = "fk_your_access_key";

type Status = "idle" | "sending" | "success" | "error";
type SubmitResult = { success?: boolean; message?: string };

export default function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [notice, setNotice] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    if (!form.reportValidity()) return;
    const body = new FormData(form);
    body.set("access_key", ACCESS_KEY);
    body.set("_lang", "es");
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body,
      });
      const result = (await response.json()) as SubmitResult;
      if (!response.ok || !result.success) {
        setStatus("error");
        setNotice(result.message || "No se pudo enviar. Inténtalo de nuevo.");
        return;
      }
      form.reset();
      setStatus("success");
      setNotice(result.message || "Enviado. ¡Gracias!");
    } catch {
      setStatus("error");
      setNotice("Error de conexión. Inténtalo de nuevo.");
    }
  }

  const sending = status === "sending";
  return (
    <form onSubmit={onSubmit}>
      <label>{"Nombre"} <input name="name" autoComplete="name" placeholder={"Tu nombre"} required /></label>
      <label>{"Correo electrónico"} <input name="email" type="email" autoComplete="email" placeholder={"tu@ejemplo.com"} required /></label>
      <label>{"Mensaje"} <textarea name="message" placeholder={"¿En qué podemos ayudarte?"} required /></label>
      <div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={sending}>{sending ? "Enviando…" : "Enviar"}</button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Componente React listo en GitHub: github.com/formgong/react-contact-form. Licencia MIT: pulsa «Use this template» o copia los archivos.

Paquete de React: npm install @formgong/react (npm: @formgong/react)

Añadir a un proyecto existente: npx formgong init (npm: formgong)

Cómo funcionan los formularios en React

React dibuja el formulario. No envía el correo. Vite y Create React App entregan archivos estáticos, así que el navegador necesita una URL que acepte el POST.

Esa URL es Formgong. El componente envía FormData con fetch a /submit. Van tu access_key público, _lang y un campo botcheck vacío. Formgong filtra el spam y entrega el aviso.

Qué cambia aquí

  • Telegram llega al momento también en el plan gratis. El email va incluido, como resumen diario para un destinatario.
  • Los envíos se guardan en la UE, en Cloudflare D1 bajo jurisdicción de la UE.
  • El filtro de spam no pide CAPTCHA ni cookies. El honeypot está siempre activo. Turnstile es opcional.
  • La clave de acceso del componente es pública. No metes una contraseña SMTP ni una clave privada de API en el bundle.
  • El texto de error y la página de gracias pueden seguir el idioma del visitante. Hay 12 idiomas.

¿Por qué no EmailJS ni tu propia ruta API?

¿Por qué no EmailJS ni tu propia ruta API?
EmailJSTu ruta de APIFormgong
Qué añadesUn paquete npm, un service id, un template id y una clave pública.Una función serverless y un cliente SMTP como Nodemailer.Una clave de acceso pública en el componente de esta página.
EmailCorreo al momento desde tu plantilla. El plan gratis son 200 peticiones al mes y 2 plantillas.Correo al momento si tu proveedor acepta el mensaje.Un resumen diario en el plan gratis. Cada aviso por email está en Pro y Business.
TelegramNo forma parte de EmailJS. El servicio envía email.Solo si escribes tú esa llamada.Entrega al momento, ya en el plan gratis.
Claves en el navegadorLa clave pública debe ser pública. Guarda la privada en un servidor.La contraseña SMTP se queda en el entorno del host. Esa es la fuerza de esta vía.La clave de acceso es pública. No hay secreto SMTP.
SpamLos límites los pones en el panel de EmailJS. La guía de Webtips de 2024 no añade nada.El filtro lo construyes tú.El honeypot está siempre activo. Sin cookies. Un CAPTCHA no es obligatorio.
Cupo gratis200 peticiones al mes; lo que pase se descarta.El cupo gratis de tu host, más el límite de tu proveedor de correo.300 envíos al mes.
Dónde están los datosEmailJS guarda el historial en sus servidores. El plan gratis guarda 7 días. La página de precios no dice que sea solo la UE.Donde los guarden tu host y tu proveedor de correo.En la UE, en Cloudflare D1.

Elige EmailJS cuando el correo deba salir de tu buzón, con tu plantilla, en el momento del envío.

Elige una función de Vercel o Netlify cuando el envío deba actualizar tu base o comprobar que el visitante ha iniciado sesión.

Elige Formgong para un formulario de contacto. Telegram es inmediato en el plan gratis. El email de ese plan es un resumen diario. No guardas una clave SMTP.

Las cifras de EmailJS salen de su página de precios del 6 de octubre de 2026. La columna de la ruta API es una función de Vercel o Netlify más SMTP, no la lista de precios de un solo servicio.

Configuración en React

  1. Crea un formulario en Formgong y copia la clave de acceso. Se parece a fk_your_access_key.
  2. Pega el componente de arriba en la app y sustituye esa clave.
  3. Muestra el componente, abre el sitio y envíate una prueba.
  4. En Formgong, abre el formulario y conecta Telegram si quieres un aviso en el teléfono.

Preguntas y respuestas

¿Hace falta un backend para un formulario de contacto en React?

No. Envía FormData con fetch a Formgong. No añades una ruta API, Nodemailer ni SMTP.

¿Cómo envío un email desde React sin Nodemailer ni SMTP?

Usa el componente de esta página. Formgong acepta el POST y filtra el spam. Telegram recibe el aviso al momento. En el plan gratis el email es un resumen diario. Pro y Business te escriben por cada envío.

¿Es seguro poner la clave en el código del cliente?

Sí. La access_key es pública. Solo puede enviar este formulario. No puede leer la bandeja. En Pro puedes limitar los dominios permitidos. No pongas una contraseña SMTP en el bundle.

¿Cómo paro el spam en un formulario React?

Deja botcheck vacío. Formgong puntúa el spam sin cookies y sin un CAPTCHA obligatorio. Turnstile es opcional si más tarde quieres un reto.

¿Funciona con Next.js?

Sí. En el App Router, añade "use client" al inicio del archivo. El componente sigue igual. La variante de Next.js en esta página muestra el archivo.

Empezar gratis