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

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 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.

  3. 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.

  4. 04

    Add the action

    app/routes/signup.tsx below. The action reads the FormData and sends, and the same route renders the form.

Install
npm install @emails.sh/sdk
app/lib/email.server.ts
import { 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)

The route
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

A resource route
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

01

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.

02

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.

03

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.

04

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.