Send email from Remix
Remix actions already run on the server, so the send belongs there and nowhere else. This page gives you the route module with its action, plus the .server.ts module that holds the client so the bundler can never pull your key into the browser.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Put it in .env
EMAILSSH_API_KEY. Remix reads .env in development through the Vite plugin, and in production you set it on your host.
- 03
Isolate it in app/lib/email.server.ts
Remix treats any module ending in .server.ts as server-only and removes it from the client bundle, so an accidental import from a component is a build error rather than a leak.
- 04
Add the action
app/routes/signup.tsx below. The action reads the FormData and sends, and the same route renders the form.
npm install @emails.sh/sdkimport { Emailssh } from '@emails.sh/sdk';
// The .server.ts suffix is what keeps this module, and the key it reads, out of
// the browser bundle. Import it from actions and loaders only.
export const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });
export const FROM = process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>';The route
app/routes/signup.tsx (Remix v2)
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { mail, FROM } from '~/lib/email.server';
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const email = String(form.get('email') ?? '');
const name = String(form.get('name') ?? 'there');
if (!email.includes('@')) {
return json({ error: 'A valid email is required' }, { status: 400 });
}
const sent = await mail.send({
from: FROM,
to: [email],
subject: 'Welcome to Acme',
html: `<p>Hi ${name}, your Acme account is ready.</p>`,
text: `Hi ${name}, your Acme account is ready.`
});
return json({ id: sent.id });
}
export default function Signup() {
const data = useActionData<typeof action>();
const navigation = useNavigation();
return (
<Form method="post">
<input name="name" placeholder="Your name" />
<input name="email" type="email" placeholder="you@example.com" required />
<button type="submit" disabled={navigation.state === 'submitting'}>
{navigation.state === 'submitting' ? 'Sending' : 'Sign up'}
</button>
{data && 'id' in data ? <p>Check your inbox.</p> : null}
{data && 'error' in data ? <p>{data.error}</p> : null}
</Form>
);
}A resource route
app/routes/api.send.tsx, for callers that are not your own form
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { mail, FROM } from '~/lib/email.server';
// A route module with no default export is a resource route: it returns data,
// never HTML, so it is the right shape for webhooks and other services.
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Use POST' }, { status: 405 });
}
const body = (await request.json()) as { email?: string; subject?: string; html?: string };
if (!body.email?.includes('@')) {
return json({ error: 'A valid email is required' }, { status: 400 });
}
const sent = await mail.send({
from: FROM,
to: [body.email],
subject: body.subject ?? 'Welcome to Acme',
html: body.html ?? '<p>Your Acme account is ready.</p>',
text: 'Your Acme account is ready.'
});
return json({ id: sent.id, status: sent.status });
}Worth knowing
The .server.ts suffix is the guardrail
Remix strips those modules from the client build. If you inline new Emailssh() into the route file instead, the import is shared between the action and the component, and you are relying on tree shaking to save you.
A slow send blocks the redirect
The action does not return until the send resolves, so the user waits. For anything not on the critical path, hand it to a queue or a background job and return immediately.
Cloudflare Pages changes where the key comes from
On @remix-run/cloudflare there is no process.env. The key arrives as context.cloudflare.env.EMAILSSH_API_KEY, so construct the client inside the action from that instead of at module scope.
React Router 7 is the same code
If you migrated to React Router 7 framework mode, only the import specifiers change (react-router instead of @remix-run/*). The action body is unchanged.
Questions
Where does the API key go in Remix?
In .env locally and in your host env in production, read only from a .server.ts module or directly inside an action or loader.
Can I send email from a Remix loader?
You can, but do not. Loaders run on navigations and prefetches, so a loader that sends will send more than once. Sends belong in actions.
How do I show a success message after sending?
Return json({ id: sent.id }) from the action and read it with useActionData, as in the route above.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.