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.
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
| EmailJS | Server Action + Resend (this repo) | |
|---|---|---|
| Backend required | None | A Next.js server (any deployment with Server Actions support) |
| Where the send happens | EmailJS's own relay infrastructure | Your server, via your own email provider's API |
| Secrets in the client bundle | Service ID, template ID, public key — all three, by design | None |
| Security model | Origin restriction on the public key, configured in EmailJS's dashboard | The key never leaves the server; there's nothing for an origin check to protect |
| Rate limiting / spam control | EmailJS plan limits + your own client-side checks | Server-side honeypot, length caps, and whatever rate limiting your server enforces |
| Setup cost | npm install, a dashboard template, done | A Server Action, a provider API key, a .env entry |
| Fits best | Static sites, no-backend prototypes, quick contact widgets | Any 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
| Symptom | Cause | Fix |
|---|---|---|
| EmailJS works locally but fails on the deployed domain | The deployed origin isn't in the key's allowed-origins list | Add the production domain in EmailJS's dashboard for that key |
| Someone else's site is sending mail using your EmailJS key | The service/template/public-key trio was copied out of your bundle — it's plain text | Restrict 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 preventDefault | Server 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 visitor | The email was sent without setting reply_to to the visitor's address | Set reply_to: email so a reply goes straight back to them |
| Bot submissions still reach your inbox despite a honeypot field | The honeypot check runs client-side, where a bot posting directly to the endpoint skips it | Check 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.
