Frameworks and agents · Updated 2026-09-28
Send email from React Router
A React Router action, formerly Remix, that emails a download link to the person who asked for it and refuses a malformed address before the API is called.
To send email from a React Router app, or a Remix app, put the send in the route's action: it runs on the server, reads the form, calls AgentiSend and returns the message id. The action below emails a download link to the person who asked for it. It refuses a malformed address before the API is called, and sends once per address however many times the form is posted. It is executed against a real API on every build of this site's repository, called with a real form post: one send, and one malformed address refused with nothing sent.
Install
pnpm add agentisendEnvironment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The action
Put it in app/routes/report.tsx, beside the route's component. With generated route types, Route.ActionArgs from ./+types/report works the same as ActionFunctionArgs. On Remix, import ActionFunctionArgs and json from @remix-run/node and return json(…) where this returns data(…).
/**
* React Router (framework mode, formerly Remix) — the `action` of
* `app/routes/report.tsx`, which emails a download link to the person who
* asked for it.
*
* An action runs on the server, so the API key never reaches the browser, and
* a `<Form method="post">` posts to it with or without JavaScript. The route's
* component reads what the action returns with `useActionData()`.
*/
import { data, type ActionFunctionArgs } from 'react-router';
import { AgentiSend, AgentiSendError } from 'agentisend';
const agentisend = new AgentiSend();
function mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const email = String(form.get('email') ?? '').trim();
if (!ADDRESS.test(email)) {
return data({ error: 'Enter an email address.' }, { status: 400 });
}
try {
const { id } = await agentisend.emails.send(
{
from: mailFrom(),
to: email,
subject: 'Your copy of the report',
text: 'Download it here: https://app.example.com/reports/2026-state-of-email.pdf',
},
// The same person asking twice gets one email.
{ idempotencyKey: `report-download/${email}` },
);
return data({ id });
} catch (err) {
if (err instanceof AgentiSendError) {
return data({ code: err.code, fix: err.fix }, { status: err.status });
}
throw err;
}
}The form
import { Form, useActionData } from 'react-router';
export default function Report() {
const result = useActionData<typeof action>();
return (
<Form method="post">
<input type="email" name="email" required />
<button type="submit">Email me the report</button>
{result && 'id' in result ? <p>Sent. Check your inbox.</p> : null}
</Form>
);
}<Form> posts to the action with or without JavaScript, and the key never leaves the server.
The key and its budget
Give the app a key of its own rather than the one you signed in with. POST /api-keys with sending_access mints a key that can send and read what it sent, and nothing else. PATCH /limits/keys/{id} puts a ceiling on it with budget_per_period and period, so a form that is flooded stops at a number you chose: past it a send is refused with agent_budget_exceeded, and the action returns that code and its fix.
The idempotency key
Every send carries Idempotency-Key: report-download/<recipient>, derived from the request and never from the moment. A double submit, or a retry after a timeout, replays the first send instead of mailing the person twice.