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

Installbash
pnpm add @rkiza/sndr
# or
npm install @rkiza/sndr
# or
yarn add @rkiza/sndr
Environmentenv
# .env (loaded by Remix's runtime per its docs)
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...   # only if you receive webhooks

Recipes

The patterns you actually need.

  1. 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>
      );
    }
  2. 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;
      }
    }
  3. 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');
    }
  4. 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?
Yes. React Router v7 merged the Remix server primitives upstream — the action / loader / resource-route shape is identical. The SDK works unchanged in both Remix v2 and React Router v7.
Will it work on Cloudflare Workers?
Enable nodejs_compat for the entire SDK on Workers. Use a Node runtime on Vercel. For runtimes without Node crypto, call the HTTP API directly.
How do I keep the API key out of the client bundle?
Never instantiate Sndr in code that runs in the browser. Actions, loaders, resource routes, and middleware all run server-side. Importing the SDK from a route component (top-level) is also fine because Remix tree-shakes server-only code out of the client bundle.
How do I avoid double-sending on retries?
The SDK auto-generates a UUID Idempotency-Key per logical send and reuses it across automatic retries. You can override with a stable key from your domain (e.g. an order ID) via the idempotencyKey field on RequestOptions.

Send your first Remix email.

3,000 emails a month on the Free plan. No credit card needed.