I run a one-person web design agency for now. Real estate clients, mostly. For a while, the contact form on my own site, the thing that actually brings me leads was wired up with EmailJS.
It worked. Until it didn't feel like enough.
The problem with EmailJS
EmailJS runs client-side. Your form calls their API directly from the browser using a public key. That's fine for a weekend project. It's not fine when:
You're rate-limited by their infra, not yours
You have zero control over deliverability
The email template lives in their dashboard's editor, and looks like every other EmailJS template on the internet
There's no way to log a submission before the send attempt, so a failed send can just... disappear
None of that is dramatic. It's just not something you want sitting between you and a lead who's deciding whether to trust you with a five-figure project.
The fix: move it server-side
I swapped EmailJS for Resend (transactional email API) + React Email (build the template as actual React components, not HTML-editor spaghetti).
- Install
npm install resend
npm install react-email @react-email/components -D
- Structure
/server
/emails
/components
Header.jsx
Footer.jsx
ContactConfirmation.jsx
/api
contact.js
Kept this entirely on the server. The Resend API key has no business anywhere near a client bundle — that's the exact problem I was leaving behind.
3. Build the template
React Email gives you real components — Html, Body, Container, Section, Button, Hr — that compile down to email-safe HTML (inline styles, none of the CSS support you'd expect from a browser). A stripped-down shell:
import { Html, Head, Body, Container, Preview } from '@react-email/components';
import Header from './components/Header';
import EmailBody from './components/Body';
import Footer from './components/Footer';
export default function ContactConfirmation({ name }) {
return (
<Html>
<Head />
<Preview>Got your inquiry — here's what happens next</Preview>
<Body style={main}>
<Container style={container}>
<Header />
<EmailBody name={name} />
<Footer />
</Container>
</Body>
</Html>
);
}
Local preview with npm run email:dev — hot reloads as you edit, so you're not blind-sending test emails to check a padding change.
- Wire it to Resend
import { Resend } from 'resend';
import ContactConfirmation from '../emails/ContactConfirmation';
const resend = new Resend(process.env.RESEND_API_KEY);
export default async function handler(req, res) {
const { name, email } = req.body;
try {
await resend.emails.send({
from: 'Jeffrey at Velto <jeffrey@velto.agency>',
to: email,
subject: "Got your inquiry — here's what happens next",
react: <ContactConfirmation name={name} />,
});
res.status(200).json({ success: true });
} catch (error) {
console.error('Email send failed:', error);
res.status(500).json({ success: false });
}
}
Resend takes a react prop directly and handles the render server-side. No manual compile step.
- The Node/JSX trap
Plain Node can't parse JSX. First run threw:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".jsx"
Fix: run the server through tsx instead of raw node — it transforms JSX on the fly via esbuild, no separate build step.
"scripts": {
"dev": "nodemon --exec npx tsx src/index.js"
}
Used npx in the exec command specifically because nodemon invoked directly doesn't get the node_modules/.bin PATH boost that npm run gives you — a Windows-specific gotcha that cost a few minutes.
- Wire the route
import express from "express";
import handler from "./routes/contact.js";
const app = express();
app.use(express.json()); // req.body is undefined without this
app.post("/api/contact", handler); // app.use() would match every HTTP verb, not just POST
app.listen(3001);
- Sandbox testing
Before verifying a sending domain, Resend's sandbox address (onboarding@resend.dev) only delivers to the email you signed up with — a deliberate anti-abuse limit, not a bug. Good enough to confirm the pipeline works end to end before touching DNS records for domain verification.
Where it landed
Form submit → Express route → Resend → inbox(it landed in my spam though), fully branded, fully under my control. No public API key in the client, no fighting a WYSIWYG template editor, and a confirmation email that actually looks like it came from a person instead of a form.
Top comments (0)