Franco Seiler — Software StudioFranco Seiler — Software StudioBootcampYouTube
← Back to blog

From EmailJS to server-side sending: why keys don't belong in the browser

EmailJS is convenient until you see your credentials sitting in the bundle. The migration to an API route, double opt-in, and what it does for deliverability.

By Franco Seiler3 min readLeer en español
Contact form on a web platform

I used EmailJS on this very site. It's genuinely convenient: you drop in a script, configure a template, and your contact form works without writing a single line of backend.

Then I looked at the production bundle and found my keys sitting there in plain text.

How the attack looks

EmailJS works by calling their API from the browser. For that to work, the browser needs the credentials:

// This ships to the client. All of it.
emailjs.send(
  'service_a8f3k2',   // service ID
  'template_x9m4p',   // template ID
  formData,
  'user_K3nP9qL2mX'   // public key
);

Anyone can open DevTools, read those three values, and send emails through your account from a script:

# With the values from the bundle, this works from anywhere.
curl -X POST https://api.emailjs.com/api/v1.0/email/send \
  -H 'Content-Type: application/json' \
  -d '{"service_id":"service_a8f3k2","template_id":"template_x9m4p","user_id":"user_K3nP9qL2mX","template_params":{...}}'

The consequences aren't hypothetical. Your monthly quota burns in minutes. Worse: the emails go out from your verified domain, so spam sent by someone else damages your sending reputation — and that's slow and painful to repair.

EmailJS offers domain restrictions to mitigate this. It helps, but it's a filter on an Origin header, and headers can be forged outside a browser.

The fix: the key never leaves the server

The migration is smaller than it sounds. The form stops calling the email provider and calls your own API route instead:

// app/api/contact/route.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY); // server-side only

export async function POST(request: Request) {
  const { name, email, message } = await request.json();

  if (!name || !email || !message) {
    return Response.json({ error: 'Missing fields' }, { status: 400 });
  }

  await resend.emails.send({
    from: 'Contact <hello@yourdomain.com>',
    to: 'you@yourdomain.com',
    replyTo: email,
    subject: `New message from ${name}`,
    text: message,
  });

  return Response.json({ ok: true });
}

RESEND_API_KEY has no NEXT_PUBLIC_ prefix, so Next.js never includes it in the client bundle. The browser sends the form to your own domain and never sees a credential.

Note replyTo: the email is sent by your domain but replies go to whoever wrote you. Sending it from the visitor's address would fail SPF and land you in spam.

What you gain beyond security

Once you own the endpoint, things that were impossible before become straightforward:

Rate limiting. Without it, your form is a spam relay with your name on it.

Real validation. Server-side, where it can't be bypassed by disabling JavaScript.

Double opt-in for the newsletter. This is the one that matters most for deliverability. Instead of adding an address to your list on submit, you send a confirmation link:

const token = crypto.randomBytes(32).toString('hex');
await db.pendingSubscriber.create({
  data: { email, token, expiresAt: addHours(new Date(), 24) },
});

await resend.emails.send({
  from: 'Newsletter <news@yourdomain.com>',
  to: email,
  subject: 'Confirm your subscription',
  html: `<a href="${process.env.SITE_URL}/api/newsletter/confirm?token=${token}">Confirm</a>`,
});

The address only joins the list when someone clicks. That kills two problems: bots signing up fake addresses, and people typing someone else's email. Both inflate your bounce rate, and a high bounce rate is what gets a sending domain flagged.

What it cost

You need somewhere to run server code — trivial if you're already on Next.js or similar, a real change if your site is fully static. And double opt-in requires storing pending subscriptions, which means a database you might not have had.

The trade is worth it. EmailJS is fine for a prototype or an internal tool with no public exposure. For anything with a public form, publishing credentials to every visitor is a cost that shows up later, and at the worst moment.

Wrapping up

The rule is simple: if a key can do something you wouldn't let an anonymous visitor do, it can't be in the browser. The browser is a hostile environment where you can't keep secrets — the same reason the BFF pattern exists.

Get new articles by email

One email when we publish something new. No spam, no promotions, unsubscribe in one click.

From EmailJS to server-side sending: why keys don't belong in the browser | Franco Seiler