Back to Blog

How to Send Emails in Remix (2026 Guide)

12 min read

Remix runs server code in action and loader functions. Email sending happens in actions (for form submissions) or resource routes (for API endpoints). API keys stay on the server, and you get progressive enhancement for free.

This guide covers both patterns with working examples.

Pick a Provider

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences from one SDK. Native Stripe integration.
  • Resend is developer-friendly. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise option. Good for high volume.

Install

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail

Add API key to .env and create a shared client:

app/lib/email.server.ts
import Sequenzy from "sequenzy";

export const sequenzy = new Sequenzy();
// Reads SEQUENZY_API_KEY from env automatically
app/lib/email.server.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
app/lib/email.server.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export { sgMail };

The .server.ts suffix ensures this code never runs in the browser.

Send from an Action

app/routes/contact.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { sequenzy } from "~/lib/email.server";

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const message = formData.get("message") as string;

if (!email || !message) {
  return json({ error: "All fields required" }, { status: 400 });
}

try {
  await sequenzy.transactional.send({
    to: "you@yourcompany.com",
    subject: `Contact from ${email}`,
    body: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
  });
  return json({ success: true });
} catch {
  return json({ error: "Failed to send" }, { status: 500 });
}
}

export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";

return (
  <Form method="post">
    <input name="email" type="email" placeholder="Your email" required />
    <textarea name="message" placeholder="Message" required />
    <button type="submit" disabled={sending}>
      {sending ? "Sending..." : "Send"}
    </button>
    {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
    {actionData?.success && <p style={{ color: "green" }}>Sent!</p>}
  </Form>
);
}
app/routes/contact.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { resend } from "~/lib/email.server";

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const message = formData.get("message") as string;

if (!email || !message) {
  return json({ error: "All fields required" }, { status: 400 });
}

const { error } = await resend.emails.send({
  from: "Contact <noreply@yourdomain.com>",
  to: "you@yourcompany.com",
  subject: `Contact from ${email}`,
  html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
});

if (error) {
  return json({ error: "Failed to send" }, { status: 500 });
}

return json({ success: true });
}

export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";

return (
  <Form method="post">
    <input name="email" type="email" placeholder="Your email" required />
    <textarea name="message" placeholder="Message" required />
    <button type="submit" disabled={sending}>
      {sending ? "Sending..." : "Send"}
    </button>
    {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
    {actionData?.success && <p style={{ color: "green" }}>Sent!</p>}
  </Form>
);
}
app/routes/contact.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { sgMail } from "~/lib/email.server";

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const message = formData.get("message") as string;

if (!email || !message) {
  return json({ error: "All fields required" }, { status: 400 });
}

try {
  await sgMail.send({
    to: "you@yourcompany.com",
    from: "noreply@yourdomain.com",
    subject: `Contact from ${email}`,
    html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
  });
  return json({ success: true });
} catch {
  return json({ error: "Failed to send" }, { status: 500 });
}
}

export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";

return (
  <Form method="post">
    <input name="email" type="email" placeholder="Your email" required />
    <textarea name="message" placeholder="Message" required />
    <button type="submit" disabled={sending}>
      {sending ? "Sending..." : "Send"}
    </button>
    {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
    {actionData?.success && <p style={{ color: "green" }}>Sent!</p>}
  </Form>
);
}

Forms work without JavaScript (progressive enhancement). When JS loads, submissions happen without a full page reload.

Resource Routes (API Endpoints)

For webhooks or programmatic email sending, use resource routes (no UI component):

// app/routes/api.send-email.ts
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { sequenzy } from "~/lib/email.server";
 
export async function action({ request }: ActionFunctionArgs) {
  const { to, subject, body } = await request.json();
  const result = await sequenzy.transactional.send({ to, subject, body });
  return json(result);
}

Going to Production

1. Verify Your Domain

Add SPF, DKIM, DMARC DNS records.

2. Use .server.ts Files

The .server.ts suffix guarantees code only runs on the server. Use it for all email-related modules.

3. Progressive Enhancement

Remix forms work without JavaScript. Your email features work even if the client bundle fails to load.

Beyond Transactional

Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration for SaaS.

Wrapping Up

  1. Action functions for form-based email sending
  2. Resource routes for API-style endpoints
  3. .server.ts for server-only email modules
  4. Progressive enhancement for reliable forms

Pick your provider, copy the patterns, and start sending.