Skip to main content
ASoc
Tutorial

React + EmailJS: What Ships in Your Bundle vs. a Server Action

EmailJS ships its service ID, template ID and public key in your bundle by design. This storefront's contact form ships none of that — the two files that decide it.

The ASoc Team9 min read

EmailJS lets a React app send email straight from the browser, with no backend at all — you call emailjs.send() with a service ID, a template ID and a public key, and EmailJS's own servers relay it. That's also exactly what it costs: those three identifiers ship in your client bundle, visible to anyone who opens dev tools, by design. This storefront's own contact form takes the other path — a Server Action that never sends a single secret to the browser — and the two files behind it are the clearest way to see the trade.

The short answer

EmailJS: install the SDK, call emailjs.send(serviceId, templateId, params, publicKey) from a client component, and EmailJS's hosted service delivers the mail — zero backend code, but the service/template IDs and public key are necessarily client-visible. A Next.js Server Action does the send server-side instead: the client ships a plain <form action={formAction}>, and the API key never leaves the server.

What EmailJS actually is

npm install @emailjs/browser
import emailjs from "@emailjs/browser";

function ContactForm() {
  const sendEmail = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    emailjs.sendForm(
      "service_xxxx",   // service ID — visible in the bundle
      "template_xxxx",  // template ID — visible in the bundle
      e.currentTarget,
      "public_key_xxxx" // public key — visible in the bundle, by design
    );
  };
  return <form onSubmit={sendEmail}>{/* fields */}</form>;
}

That's the whole integration, and it's genuinely useful for what it's built for: a static site or a prototype with no server at all. EmailJS's own documentation is explicit that the key is meant to be public — their security model is origin restriction (you configure which domains a key is allowed to send from in their dashboard), not secrecy. The trade is that the identifiers are visible to anyone reading your page source, and delivery routes through EmailJS's infrastructure rather than the transactional-email provider you might already use for other mail.

What this storefront does instead

// src/components/molecules/ContactForm.tsx — the whole client side
"use client";
import { useActionState } from "react";
import { sendContactMessage } from "@/lib/actions/contact";

export default function ContactForm() {
  const [state, formAction, pending] = useActionState(sendContactMessage, null);
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      {/* a hidden honeypot field, and a submit button */}
    </form>
  );
}

No SDK, no service ID, no API key, no public key — the client bundle for this component contains a form and a reference to a server function. The send itself happens here, entirely server-side:

// src/lib/actions/contact.ts
"use server";
export async function sendContactMessage(_prev: FormState, formData: FormData) {
  // Honeypot: real users never fill this hidden field.
  if (formData.get("company")) return { ok: true, message: "Message sent." };

  const email = String(formData.get("email") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim().slice(0, 5000);
  if (!EMAIL_RE.test(email)) {
    return { ok: false, message: "Please enter a valid email address." };
  }

  const apiKey = process.env.RESEND_API_KEY; // never sent to the client
  const res = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "ASoc Contact <noreply@asoctemplates.com>",
      to: ["support@asoctemplates.com"],
      reply_to: email, // replies go to the visitor, not into a shared inbox thread
      subject: "New contact form message",
      text: `From: ${email}\n\n${message}`,
    }),
  });
  // ...error handling
}

Three things this buys that a client-side send can't: RESEND_API_KEY never exists in a bundle to leak; a server-side honeypot field silently accepts (rather than errors on) bot submissions, which keeps automated scrapers from learning the form is being filtered at all; and reply_to: email means a support reply goes straight back to the visitor without exposing the shared support@ inbox as the visible sender.

The comparison that matters

EmailJSServer Action + Resend (this repo)
Backend requiredNoneA Next.js server (any deployment with Server Actions support)
Where the send happensEmailJS's own relay infrastructureYour server, via your own email provider's API
Secrets in the client bundleService ID, template ID, public key — all three, by designNone
Security modelOrigin restriction on the public key, configured in EmailJS's dashboardThe key never leaves the server; there's nothing for an origin check to protect
Rate limiting / spam controlEmailJS plan limits + your own client-side checksServer-side honeypot, length caps, and whatever rate limiting your server enforces
Setup costnpm install, a dashboard template, doneA Server Action, a provider API key, a .env entry
Fits bestStatic sites, no-backend prototypes, quick contact widgetsAny app that already has a server and wants the send auditable and rate-limited there

Neither is "wrong" — EmailJS solves a real problem (mail with zero backend) at a real cost (three identifiers in plain sight). A Server Action needs a server to exist, which this repo already has for everything else, so the marginal cost of routing mail through it is close to zero and the security trade goes the other way entirely.

The failure mode each one has to guard against

EmailJS's guard is origin restriction — if someone copies your service/template/public-key trio out of your bundle (trivial, since it's plain text in the shipped JS), the dashboard-configured allowed-origins list is what stops them from using it to send from their own page. That's a real, working control, but it's the only one, and it lives in a third-party dashboard rather than your own code.

The Server Action's guard is different: since the key is never exposed, there's no "restrict where the key can be used from" problem to solve at all. What it has to guard instead is the endpoint itself accepting spam — which is what the honeypot field and the length cap on message are for. Neither approach eliminates abuse entirely; they eliminate different classes of it.

Deliverability: whose domain is actually sending

There's a second difference the security comparison alone doesn't cover: whose sending domain shows up to the recipient's mail server. EmailJS relays through its own infrastructure by default, which means SPF/DKIM alignment is EmailJS's domain, not yours — fine for a contact-form notification landing in your own inbox, but a weaker signal if you ever want the mail to look authoritative to a recipient's spam filter. Sending through your own provider's API, as this repo does with Resend and a verified asoctemplates.com sending domain, means the from address's domain matches the DNS records proving you're allowed to send as that domain — the same alignment problem this repo's own transactional-email post (linked below) covers for order-confirmation mail.

That distinction doesn't matter for every use case — a low-volume contact form where you're the only recipient rarely hits a spam filter either way — but it's the reason a growing product usually migrates off a client-side relay and onto its own verified sending domain once outbound volume or deliverability starts to matter.

Troubleshooting

SymptomCauseFix
EmailJS works locally but fails on the deployed domainThe deployed origin isn't in the key's allowed-origins listAdd the production domain in EmailJS's dashboard for that key
Someone else's site is sending mail using your EmailJS keyThe service/template/public-key trio was copied out of your bundle — it's plain textRestrict allowed origins, and rotate the key if abuse is happening
Server Action form works with JS but not without it<form action={formAction}> is the progressive-enhancement path — check nothing intercepts submission with preventDefaultServer Actions bound to action submit natively even with JavaScript disabled
Contact form replies land in a shared inbox with no way to reply to the visitorThe email was sent without setting reply_to to the visitor's addressSet reply_to: email so a reply goes straight back to them
Bot submissions still reach your inbox despite a honeypot fieldThe honeypot check runs client-side, where a bot posting directly to the endpoint skips itCheck the honeypot field server-side, in the Server Action itself

Frequently asked questions

Is EmailJS insecure? Not insecure for what it's built for — it's built to be called from the client, and its own model treats the public key, service ID and template ID as visible. The risk is specific: if origin restriction isn't configured, anyone who copies those three values can send mail through your account.

Can I use EmailJS with a Next.js Server Action instead of client-side? You could call EmailJS's API from a Server Action, but at that point you're paying for EmailJS's relay while gaining none of its no-backend benefit — if you already have a server, calling your email provider's API directly (as this repo does with Resend) skips a hop.

Why does this form use a honeypot instead of a CAPTCHA? A hidden field real users never see and never fill costs nothing in friction; unlike a CAPTCHA it adds no interaction step for legitimate visitors, at the cost of only stopping unsophisticated bots rather than targeted abuse.

What does useActionState do here that a plain onSubmit handler wouldn't? It wires the form to a Server Action while tracking pending/result state without a separate fetch call or API route — the same submission works with JavaScript disabled, since the action attribute posts the form natively either way.

How does this relate to transactional email, like an order confirmation? A contact form is one-off, visitor-initiated mail; an order confirmation has a stricter failure policy because it's confirming money changed hands. Transactional Email in Next.js That Can't Break the Purchase It Confirms covers that stricter case, including the sending-domain alignment question this post only touches on.

What about a waitlist or newsletter signup instead of a contact form? The same Resend-backed Server Action pattern powers this site's own waitlist capture — see Email Signup Landing Page for how a single-field email form fits into a page that has no full contact form at all.

Templates in this post

ASoc Brief, ASoc Byte and ASoc Canvas are Next.js + Tailwind landing page templates built with the same Server Action contact-form pattern described above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial10 min read

React Focus Trap: Two Dialogs Claimed aria-modal, One Meant It

An audit of four overlay surfaces: the wishlist panel that let Tab walk out of a modal dialog, the off-screen drawer pointer-events-none never hid, and the iframe case.

Read more
Tutorial10 min read

React Form Validation: The Allowlist Is the Validation

Client-side checks are UX. The server-side function that reads three named fields and ignores everything else — proven by a test that smuggles in a fake user_id — is the actual gate.

Read more
Tutorial9 min read

React Hooks: Why This Codebase's `useContext` Count Is Zero

24 useState, 13 useEffect, 0 useContext across 22 files — a real hook census, and the module-scope store pattern this codebase uses instead of Context.

Read more