Integration
Send email from Remix with SNDR.
Send transactional email from Remix (and React Router v7) — actions, loaders, resource routes, and webhook receivers. ESM-first SDK with fetch under the hood; uses Node-compatible runtimes, including Workers with nodejs_compat.
Remix's server primitives — actions, loaders, and resource routes — are a clean fit for transactional email. The SDK uses fetch and Node crypto. Use a Node-compatible target and enable nodejs_compat on Workers. The recipes below cover the four patterns every Remix codebase ends up needing: send from a form action, send from a programmatic action, verify an incoming webhook with raw-body HMAC, and a small magic-link flow.
Setup
Install + configure
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr# .env (loaded by Remix's runtime per its docs)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=... # only if you receive webhooksRecipes
The patterns you actually need.
Send from a form action
Server actions run on every form submit. Read FormData, validate, and call sndr.emails.send. Errors thrown from an action surface in your route's ErrorBoundary; Remix re-runs loaders after.
// app/routes/contact.tsx import type { ActionFunctionArgs } from '@remix-run/node'; import { Form, useActionData } from '@remix-run/react'; import { Sndr } from '@rkiza/sndr'; import { z } from 'zod'; const sndr = new Sndr(process.env.SNDR_API_KEY!); const contactSchema = z.object({ name: z.string().min(2).max(120), email: z.string().email(), message: z.string().min(10).max(8000), }); export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const parsed = contactSchema.safeParse({ name: formData.get('name'), email: formData.get('email'), message: formData.get('message'), }); if (!parsed.success) { return Response.json({ error: parsed.error.flatten() }, { status: 400 }); } await sndr.emails.send({ from: 'hello@yourdomain.com', to: 'team@yourdomain.com', reply_to: parsed.data.email, subject: `New contact from ${parsed.data.name}`, text: parsed.data.message, }); return Response.json({ ok: true }); } export default function ContactPage() { const data = useActionData<typeof action>(); return ( <Form method="post"> <input name="name" required /> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit">Send</button> {data && 'ok' in data ? <p>Message received.</p> : null} </Form> ); }Send from a resource route
Resource routes are file-based API handlers — perfect for transactional emails triggered by other services or scheduled jobs. The SDK auto-generates an Idempotency-Key per logical call and reuses it on retries.
// app/routes/api.welcome.ts import type { ActionFunctionArgs } from '@remix-run/node'; import { Sndr, SndrError } from '@rkiza/sndr'; const sndr = new Sndr(process.env.SNDR_API_KEY!); export async function action({ request }: ActionFunctionArgs) { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); } const { email, name } = (await request.json()) as { email: string; name: string }; try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); return Response.json({ id }); } catch (err) { if (err instanceof SndrError) { return Response.json( { error: { code: err.code, message: err.message } }, { status: (err.status >= 400 && err.status <= 599 ? err.status : 502) }, ); } throw err; } }Verify an incoming webhook
Webhook handlers need the raw request body to verify the HMAC signature. Remix's resource routes give you the Web standard Request object — call .arrayBuffer() to get the original bytes before they're consumed.
// app/routes/api.webhooks.sndr.ts import type { ActionFunctionArgs } from '@remix-run/node'; import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; export async function action({ request }: ActionFunctionArgs) { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); } const raw = Buffer.from(await request.arrayBuffer()); const ok = verifyWebhookSignature( raw, request.headers.get('x-sndr-signature') ?? '', process.env.SNDR_WEBHOOK_SECRET!, ); if (!ok) return new Response('bad signature', { status: 400 }); const event = JSON.parse(raw.toString('utf8')) as WebhookEventPayload; switch (event.type) { case 'email.delivered': // event.data.email_id, event.data.to: string[] break; case 'email.bounced': // event.data.email_id, event.data.error break; case 'email.unsubscribed': // event.data.addresses: string[] break; } return new Response('ok'); }Magic-link sign-in flow
A signed token persists alongside the email; the click-through validates it. Issue the token from an action, send the email, validate the token on a loader at /auth/callback.
// app/routes/api.magic-link.ts import type { ActionFunctionArgs } from '@remix-run/node'; import { Sndr } from '@rkiza/sndr'; import { randomBytes } from 'node:crypto'; const sndr = new Sndr(process.env.SNDR_API_KEY!); export async function action({ request }: ActionFunctionArgs) { const { email } = (await request.json()) as { email: string }; const token = randomBytes(32).toString('hex'); // ...persist (token, email, expiresAt) with 15-min TTL... const link = `https://yourdomain.com/auth/callback?token=${token}`; await sndr.emails.send({ from: 'auth@yourdomain.com', to: email, subject: 'Sign in to yourdomain', html: `<p><a href="${link}">Click here to sign in</a>. Link expires in 15 minutes.</p>`, }); return new Response('check your email'); }
Remix FAQ
Does the SDK work with React Router v7?
Will it work on Cloudflare Workers?
How do I keep the API key out of the client bundle?
How do I avoid double-sending on retries?
Send your first Remix email.
3,000 emails a month on the Free plan. No credit card needed.