Back to Blog

How to Send Emails in SvelteKit (2026 Guide)

18 min read

Most SvelteKit email tutorials show a form action that sends one email and call it done. That ignores password resets, payment receipts, Stripe webhooks, HTML email templates, error handling, and everything else a real app needs.

SvelteKit is excellent for email. Server code runs in +page.server.ts actions and +server.ts endpoints. $env/static/private blocks API keys from the client at compile time, not just runtime. Form actions give you progressive enhancement. And hooks let you add middleware like rate limiting across all routes.

This guide covers the full picture with TypeScript examples. For other full-stack frameworks, see our guides for Next.js, Nuxt, or Remix.

Pick a Provider

Every code example below lets you switch between three providers.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
  • Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.

Install

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

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Initialize the Client

Create a shared email client inside src/lib/server/. SvelteKit treats the server directory as a boundary: anything inside $lib/server/ is blocked from client-side imports at build time.

src/lib/server/email.ts
import Sequenzy from "sequenzy";
import { SEQUENZY_API_KEY } from "$env/static/private";

export const sequenzy = new Sequenzy({ apiKey: SEQUENZY_API_KEY });
src/lib/server/email.ts
import { Resend } from "resend";
import { RESEND_API_KEY } from "$env/static/private";

export const resend = new Resend(RESEND_API_KEY);
src/lib/server/email.ts
import sgMail from "@sendgrid/mail";
import { SENDGRID_API_KEY } from "$env/static/private";

sgMail.setApiKey(SENDGRID_API_KEY);

export { sgMail };

$env/static/private is SvelteKit's compile-time env access. If you accidentally import it in a client-side file, the build fails with a clear error. This is safer than process.env, which silently returns undefined in the browser.

Send Your First Email

Form actions are the idiomatic way to handle submissions in SvelteKit. The action runs server-side, the page component displays the result. Progressive enhancement means it works without JavaScript.

src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { sequenzy } from "$lib/server/email";

export const actions = {
default: async ({ request }) => {
  const data = await request.formData();
  const email = data.get("email") as string;
  const name = data.get("name") as string;
  const message = data.get("message") as string;

  if (!email || !name || !message) {
    return fail(400, { error: "All fields are required", email, name, message });
  }

  try {
    await sequenzy.transactional.send({
      to: "you@yourcompany.com",
      subject: `Contact from ${name}`,
      body: `
        <h2>New Contact Form Submission</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    });
    return { success: true };
  } catch {
    return fail(500, { error: "Failed to send message" });
  }
},
} satisfies Actions;
src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { resend } from "$lib/server/email";

export const actions = {
default: async ({ request }) => {
  const data = await request.formData();
  const email = data.get("email") as string;
  const name = data.get("name") as string;
  const message = data.get("message") as string;

  if (!email || !name || !message) {
    return fail(400, { error: "All fields are required", email, name, message });
  }

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

  if (error) {
    return fail(500, { error: "Failed to send message" });
  }

  return { success: true };
},
} satisfies Actions;
src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { sgMail } from "$lib/server/email";

export const actions = {
default: async ({ request }) => {
  const data = await request.formData();
  const email = data.get("email") as string;
  const name = data.get("name") as string;
  const message = data.get("message") as string;

  if (!email || !name || !message) {
    return fail(400, { error: "All fields are required", email, name, message });
  }

  try {
    await sgMail.send({
      to: "you@yourcompany.com",
      from: "noreply@yourdomain.com",
      subject: `Contact from ${name}`,
      html: `
        <h2>New Contact Form Submission</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    });
    return { success: true };
  } catch {
    return fail(500, { error: "Failed to send message" });
  }
},
} satisfies Actions;

The Svelte page with use:enhance for progressive enhancement:

<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';
 
  let { form }: { form: ActionData } = $props();
  let submitting = $state(false);
</script>
 
<form
  method="POST"
  use:enhance={() => {
    submitting = true;
    return async ({ update }) => {
      await update();
      submitting = false;
    };
  }}
>
  <input name="name" value={form?.name ?? ''} placeholder="Your name" required />
  <input name="email" type="email" value={form?.email ?? ''} placeholder="Your email" required />
  <textarea name="message" placeholder="Your message" required>{form?.message ?? ''}</textarea>
  <button type="submit" disabled={submitting}>
    {submitting ? 'Sending...' : 'Send Message'}
  </button>
 
  {#if form?.error}
    <p style="color: red">{form.error}</p>
  {/if}
  {#if form?.success}
    <p style="color: green">Message sent!</p>
  {/if}
</form>

Key SvelteKit patterns:

  • fail() returns errors with HTTP status codes. The form values (email, name) are returned so the form repopulates after errors.
  • use:enhance upgrades the form from a full-page-reload HTML form to a fetch-based submission. Without it, the form still works (progressive enhancement).
  • $state() (Svelte 5 runes) manages the submitting state for the loading indicator.
  • satisfies Actions gives type safety on the action object.

React Email Templates

Inline HTML strings get messy. React Email lets you build email templates as components that compile to email-safe HTML. Yes, you can use React Email in a SvelteKit project. You only use it server-side to render HTML strings; it never touches your Svelte components.

npm install @react-email/components react-email react react-dom

Create a layout and template:

// src/lib/server/emails/layout.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Text,
  Hr,
} from "@react-email/components";
 
interface EmailLayoutProps {
  children: React.ReactNode;
  preview?: string;
}
 
export function EmailLayout({ children, preview }: EmailLayoutProps) {
  return (
    <Html>
      <Head />
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container
          style={{
            backgroundColor: "#ffffff",
            padding: "40px",
            borderRadius: "8px",
            margin: "40px auto",
            maxWidth: "560px",
          }}
        >
          {children}
          <Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
          <Text style={{ color: "#8898aa", fontSize: "12px" }}>
            YourApp Inc. · 123 Main St · San Francisco, CA
          </Text>
        </Container>
      </Body>
    </Html>
  );
}
// src/lib/server/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./layout";
 
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}
 
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
  return (
    <EmailLayout preview={`Welcome to YourApp, ${name}`}>
      <Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
        Welcome, {name}!
      </Heading>
      <Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
        Your account is ready. Here's what to do next:
      </Text>
      <Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
        1. Set up your first project{"\n"}
        2. Invite your team{"\n"}
        3. Connect your integrations
      </Text>
      <Button
        href={loginUrl}
        style={{
          backgroundColor: "#5046e5",
          color: "#ffffff",
          padding: "12px 24px",
          borderRadius: "6px",
          textDecoration: "none",
          display: "inline-block",
          marginTop: "16px",
        }}
      >
        Go to Dashboard
      </Button>
    </EmailLayout>
  );
}

Render and send:

src/lib/server/send-welcome.ts
import { render } from "@react-email/components";
import { sequenzy } from "$lib/server/email";
import { WelcomeEmail } from "$lib/server/emails/welcome";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return sequenzy.transactional.send({
  to,
  subject: `Welcome to YourApp, ${name}!`,
  body: html,
});
}
src/lib/server/send-welcome.ts
import { render } from "@react-email/components";
import { resend } from "$lib/server/email";
import { WelcomeEmail } from "$lib/server/emails/welcome";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return resend.emails.send({
  from: "YourApp <noreply@yourdomain.com>",
  to,
  subject: `Welcome to YourApp, ${name}!`,
  html,
});
}
src/lib/server/send-welcome.ts
import { render } from "@react-email/components";
import { sgMail } from "$lib/server/email";
import { WelcomeEmail } from "$lib/server/emails/welcome";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return sgMail.send({
  to,
  from: "noreply@yourdomain.com",
  subject: `Welcome to YourApp, ${name}!`,
  html,
});
}

Everything stays inside $lib/server/ so the templates, React, and email SDKs never reach the client bundle.

Server Routes (API Endpoints)

+server.ts files create REST-style API endpoints. Use these for webhooks, programmatic email sends, or anything that doesn't need a page.

src/routes/api/send-email/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { sequenzy } from "$lib/server/email";
import { INTERNAL_API_KEY } from "$env/static/private";

export const POST: RequestHandler = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${INTERNAL_API_KEY}`) {
  throw error(401, "Unauthorized");
}

const { to, subject, body } = await request.json();

if (!to || !subject || !body) {
  throw error(400, "Missing required fields: to, subject, body");
}

try {
  const result = await sequenzy.transactional.send({ to, subject, body });
  return json({ success: true, id: result.id });
} catch {
  throw error(500, "Failed to send email");
}
};
src/routes/api/send-email/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { resend } from "$lib/server/email";
import { INTERNAL_API_KEY } from "$env/static/private";

export const POST: RequestHandler = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${INTERNAL_API_KEY}`) {
  throw error(401, "Unauthorized");
}

const { to, subject, html } = await request.json();

if (!to || !subject || !html) {
  throw error(400, "Missing required fields: to, subject, html");
}

const { data, error: sendError } = await resend.emails.send({
  from: "YourApp <noreply@yourdomain.com>",
  to,
  subject,
  html,
});

if (sendError) {
  throw error(500, sendError.message);
}

return json({ success: true, id: data?.id });
};
src/routes/api/send-email/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { sgMail } from "$lib/server/email";
import { INTERNAL_API_KEY } from "$env/static/private";

export const POST: RequestHandler = async ({ request }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${INTERNAL_API_KEY}`) {
  throw error(401, "Unauthorized");
}

const { to, subject, html } = await request.json();

if (!to || !subject || !html) {
  throw error(400, "Missing required fields: to, subject, html");
}

try {
  await sgMail.send({
    to,
    from: "noreply@yourdomain.com",
    subject,
    html,
  });
  return json({ success: true });
} catch {
  throw error(500, "Failed to send email");
}
};

Common SaaS Patterns

Password Reset

The form action generates a token and sends the reset email. Always return success regardless of whether the email exists (to prevent enumeration).

src/routes/forgot-password/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { render } from "@react-email/components";
import { sequenzy } from "$lib/server/email";
import { db } from "$lib/server/db";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
          If you didn't request this, ignore this email.
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export const actions = {
default: async ({ request, url }) => {
  const data = await request.formData();
  const email = data.get("email") as string;

  if (!email) {
    return fail(400, { error: "Email is required" });
  }

  // Always return success to prevent email enumeration
  const user = await db.user.findByEmail(email);
  if (!user) {
    return { success: true };
  }

  const token = crypto.randomBytes(32).toString("hex");
  const expires = new Date(Date.now() + 60 * 60 * 1000);
  await db.resetToken.create({ userId: user.id, token, expires });

  const resetUrl = `${url.origin}/reset-password?token=${token}`;
  const html = await render(<ResetEmail url={resetUrl} />);

  await sequenzy.transactional.send({
    to: email,
    subject: "Reset your password",
    body: html,
  });

  return { success: true };
},
} satisfies Actions;
src/routes/forgot-password/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { render } from "@react-email/components";
import { resend } from "$lib/server/email";
import { db } from "$lib/server/db";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
          If you didn't request this, ignore this email.
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export const actions = {
default: async ({ request, url }) => {
  const data = await request.formData();
  const email = data.get("email") as string;

  if (!email) {
    return fail(400, { error: "Email is required" });
  }

  const user = await db.user.findByEmail(email);
  if (!user) {
    return { success: true };
  }

  const token = crypto.randomBytes(32).toString("hex");
  const expires = new Date(Date.now() + 60 * 60 * 1000);
  await db.resetToken.create({ userId: user.id, token, expires });

  const resetUrl = `${url.origin}/reset-password?token=${token}`;
  const html = await render(<ResetEmail url={resetUrl} />);

  await resend.emails.send({
    from: "YourApp <noreply@yourdomain.com>",
    to: email,
    subject: "Reset your password",
    html,
  });

  return { success: true };
},
} satisfies Actions;
src/routes/forgot-password/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { render } from "@react-email/components";
import { sgMail } from "$lib/server/email";
import { db } from "$lib/server/db";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
          If you didn't request this, ignore this email.
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export const actions = {
default: async ({ request, url }) => {
  const data = await request.formData();
  const email = data.get("email") as string;

  if (!email) {
    return fail(400, { error: "Email is required" });
  }

  const user = await db.user.findByEmail(email);
  if (!user) {
    return { success: true };
  }

  const token = crypto.randomBytes(32).toString("hex");
  const expires = new Date(Date.now() + 60 * 60 * 1000);
  await db.resetToken.create({ userId: user.id, token, expires });

  const resetUrl = `${url.origin}/reset-password?token=${token}`;
  const html = await render(<ResetEmail url={resetUrl} />);

  try {
    await sgMail.send({
      to: email,
      from: "noreply@yourdomain.com",
      subject: "Reset your password",
      html,
    });
  } catch {
    console.error("Failed to send password reset email");
  }

  return { success: true };
},
} satisfies Actions;

The page component:

<!-- src/routes/forgot-password/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';
 
  let { form }: { form: ActionData } = $props();
</script>
 
{#if form?.success}
  <h2>Check your email</h2>
  <p>If an account exists with that email, we sent a password reset link.</p>
{:else}
  <form method="POST" use:enhance>
    <h2>Forgot your password?</h2>
    <p>Enter your email and we'll send you a reset link.</p>
    <input name="email" type="email" placeholder="you@example.com" required />
    <button type="submit">Send Reset Link</button>
    {#if form?.error}
      <p style="color: red">{form.error}</p>
    {/if}
  </form>
{/if}

Notice how the form action has access to url from the event object. This is cleaner than hardcoding process.env.APP_URL because SvelteKit knows the current origin.

Payment Receipt

src/lib/server/send-receipt.ts
import { render } from "@react-email/components";
import { sequenzy } from "$lib/server/email";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";

interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}

function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
        <Text>Hi {customerName}, thanks for your purchase!</Text>

        <Section style={{ marginTop: "24px" }}>
          {items.map((item, i) => (
            <Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
              <Column>{item.name}</Column>
              <Column align="right">{item.amount}</Column>
            </Row>
          ))}
        </Section>

        <Hr style={{ margin: "16px 0" }} />

        <Row>
          <Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
          <Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
        </Row>

        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
          View your full receipt at {receiptUrl}
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export async function sendReceipt(to: string, data: ReceiptProps) {
const html = await render(<ReceiptEmail {...data} />);

return sequenzy.transactional.send({
  to,
  subject: `Receipt for your ${data.total} payment`,
  body: html,
});
}
src/lib/server/send-receipt.ts
import { render } from "@react-email/components";
import { resend } from "$lib/server/email";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";

interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}

function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
        <Text>Hi {customerName}, thanks for your purchase!</Text>

        <Section style={{ marginTop: "24px" }}>
          {items.map((item, i) => (
            <Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
              <Column>{item.name}</Column>
              <Column align="right">{item.amount}</Column>
            </Row>
          ))}
        </Section>

        <Hr style={{ margin: "16px 0" }} />

        <Row>
          <Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
          <Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
        </Row>

        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
          View your full receipt at {receiptUrl}
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export async function sendReceipt(to: string, data: ReceiptProps) {
const html = await render(<ReceiptEmail {...data} />);

return resend.emails.send({
  from: "YourApp <billing@yourdomain.com>",
  to,
  subject: `Receipt for your ${data.total} payment`,
  html,
});
}
src/lib/server/send-receipt.ts
import { render } from "@react-email/components";
import { sgMail } from "$lib/server/email";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";

interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}

function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
        <Text>Hi {customerName}, thanks for your purchase!</Text>

        <Section style={{ marginTop: "24px" }}>
          {items.map((item, i) => (
            <Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
              <Column>{item.name}</Column>
              <Column align="right">{item.amount}</Column>
            </Row>
          ))}
        </Section>

        <Hr style={{ margin: "16px 0" }} />

        <Row>
          <Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
          <Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
        </Row>

        <Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
          View your full receipt at {receiptUrl}
        </Text>
      </Container>
    </Body>
  </Html>
);
}

export async function sendReceipt(to: string, data: ReceiptProps) {
const html = await render(<ReceiptEmail {...data} />);

return sgMail.send({
  to,
  from: "billing@yourdomain.com",
  subject: `Receipt for your ${data.total} payment`,
  html,
});
}

Stripe Webhook

Stripe sends webhooks as POST requests with a signature. In SvelteKit, handle this in a +server.ts file. Use request.text() to get the raw body for signature verification.

src/routes/api/stripe-webhook/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import Stripe from "stripe";
import { sequenzy } from "$lib/server/email";
import { sendReceipt } from "$lib/server/send-receipt";
import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from "$env/static/private";

const stripe = new Stripe(STRIPE_SECRET_KEY);

export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
  console.error("Webhook signature verification failed");
  throw error(400, "Invalid signature");
}

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    const customerEmail = session.customer_details?.email;

    if (customerEmail) {
      await sendReceipt(customerEmail, {
        customerName: session.customer_details?.name || "Customer",
        items: [{ name: "Pro Plan", amount: "$29.00" }],
        total: `$${(session.amount_total! / 100).toFixed(2)}`,
        receiptUrl: "https://yourapp.com/billing",
      });
    }
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string,
    ) as Stripe.Customer;

    if (customer.email) {
      await sequenzy.transactional.send({
        to: customer.email,
        subject: "Your subscription has been cancelled",
        body: `
          <h2>We're sorry to see you go</h2>
          <p>Your subscription has been cancelled.
          You still have access until the end of your billing period.</p>
          <p>If you change your mind, you can resubscribe anytime.</p>
        `,
      });
    }
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (invoice.customer_email) {
      await sequenzy.transactional.send({
        to: invoice.customer_email,
        subject: "Payment failed - action required",
        body: `
          <h2>Your payment didn't go through</h2>
          <p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
          <p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
        `,
      });
    }
    break;
  }
}

return json({ received: true });
};
src/routes/api/stripe-webhook/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import Stripe from "stripe";
import { resend } from "$lib/server/email";
import { sendReceipt } from "$lib/server/send-receipt";
import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from "$env/static/private";

const stripe = new Stripe(STRIPE_SECRET_KEY);

export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
  console.error("Webhook signature verification failed");
  throw error(400, "Invalid signature");
}

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    const customerEmail = session.customer_details?.email;

    if (customerEmail) {
      await sendReceipt(customerEmail, {
        customerName: session.customer_details?.name || "Customer",
        items: [{ name: "Pro Plan", amount: "$29.00" }],
        total: `$${(session.amount_total! / 100).toFixed(2)}`,
        receiptUrl: "https://yourapp.com/billing",
      });
    }
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string,
    ) as Stripe.Customer;

    if (customer.email) {
      await resend.emails.send({
        from: "YourApp <noreply@yourdomain.com>",
        to: customer.email,
        subject: "Your subscription has been cancelled",
        html: `
          <h2>We're sorry to see you go</h2>
          <p>Your subscription has been cancelled.
          You still have access until the end of your billing period.</p>
        `,
      });
    }
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (invoice.customer_email) {
      await resend.emails.send({
        from: "YourApp <billing@yourdomain.com>",
        to: invoice.customer_email,
        subject: "Payment failed - action required",
        html: `
          <h2>Your payment didn't go through</h2>
          <p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
          <p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
        `,
      });
    }
    break;
  }
}

return json({ received: true });
};
src/routes/api/stripe-webhook/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import Stripe from "stripe";
import { sgMail } from "$lib/server/email";
import { sendReceipt } from "$lib/server/send-receipt";
import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from "$env/static/private";

const stripe = new Stripe(STRIPE_SECRET_KEY);

export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
  console.error("Webhook signature verification failed");
  throw error(400, "Invalid signature");
}

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    const customerEmail = session.customer_details?.email;

    if (customerEmail) {
      await sendReceipt(customerEmail, {
        customerName: session.customer_details?.name || "Customer",
        items: [{ name: "Pro Plan", amount: "$29.00" }],
        total: `$${(session.amount_total! / 100).toFixed(2)}`,
        receiptUrl: "https://yourapp.com/billing",
      });
    }
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string,
    ) as Stripe.Customer;

    if (customer.email) {
      await sgMail.send({
        to: customer.email,
        from: "noreply@yourdomain.com",
        subject: "Your subscription has been cancelled",
        html: `
          <h2>We're sorry to see you go</h2>
          <p>Your subscription has been cancelled.
          You still have access until the end of your billing period.</p>
        `,
      });
    }
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (invoice.customer_email) {
      await sgMail.send({
        to: invoice.customer_email,
        from: "billing@yourdomain.com",
        subject: "Payment failed - action required",
        html: `
          <h2>Your payment didn't go through</h2>
          <p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
          <p><a href="https://yourapp.com/billing">Update Payment Method</a></p>
        `,
      });
    }
    break;
  }
}

return json({ received: true });
};

SvelteKit uses web-standard Request objects, so request.text() gives you the raw body directly. No special middleware or body parser configuration needed.

Error Handling

src/lib/server/send-email-safe.ts
import Sequenzy from "sequenzy";
import { sequenzy } from "$lib/server/email";

interface SendResult {
success: boolean;
error?: string;
}

export async function sendEmailSafe(
to: string,
subject: string,
body: string,
): Promise<SendResult> {
try {
  await sequenzy.transactional.send({ to, subject, body });
  return { success: true };
} catch (error) {
  if (error instanceof Sequenzy.RateLimitError) {
    console.warn(`Rate limited. Retry after: ${error.retryAfter}s`);
    return { success: false, error: "Too many emails. Try again later." };
  }

  if (error instanceof Sequenzy.AuthenticationError) {
    console.error("Invalid SEQUENZY_API_KEY");
    return { success: false, error: "Email service configuration error" };
  }

  if (error instanceof Sequenzy.ValidationError) {
    console.error(`Validation: ${error.message}`);
    return { success: false, error: "Invalid email parameters" };
  }

  console.error("Unexpected email error:", error);
  return { success: false, error: "Failed to send email" };
}
}
src/lib/server/send-email-safe.ts
import { resend } from "$lib/server/email";

interface SendResult {
success: boolean;
error?: string;
id?: string;
}

export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const { data, error } = await resend.emails.send({
  from: "YourApp <noreply@yourdomain.com>",
  to,
  subject,
  html,
});

if (error) {
  console.error(`Resend error [${error.name}]: ${error.message}`);

  switch (error.name) {
    case "rate_limit_exceeded":
      return { success: false, error: "Too many emails. Try again later." };
    case "validation_error":
    case "missing_required_field":
      return { success: false, error: "Invalid email parameters" };
    case "invalid_api_key":
      return { success: false, error: "Email service configuration error" };
    default:
      return { success: false, error: "Failed to send email" };
  }
}

return { success: true, id: data?.id };
}
src/lib/server/send-email-safe.ts
import { sgMail } from "$lib/server/email";

interface SendResult {
success: boolean;
error?: string;
}

export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
try {
  await sgMail.send({
    to,
    from: "noreply@yourdomain.com",
    subject,
    html,
  });
  return { success: true };
} catch (error: unknown) {
  const sgError = error as { code?: number; response?: { body?: { errors?: { message: string }[] } } };

  if (sgError.code === 429) {
    return { success: false, error: "Too many emails. Try again later." };
  }

  if (sgError.code === 401) {
    console.error("Invalid SendGrid API key");
    return { success: false, error: "Email service configuration error" };
  }

  const messages = sgError.response?.body?.errors?.map((e) => e.message);
  if (messages?.length) {
    console.error("SendGrid errors:", messages);
  }

  return { success: false, error: "Failed to send email" };
}
}

Use the safe wrapper in actions:

// src/routes/invite/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { sendEmailSafe } from "$lib/server/send-email-safe";
 
export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = data.get("email") as string;
 
    const result = await sendEmailSafe(
      email,
      "You've been invited!",
      "<p>Click here to join the team.</p>",
    );
 
    if (!result.success) {
      return fail(500, { error: result.error });
    }
 
    return { success: true };
  },
} satisfies Actions;

Rate Limiting with Hooks

SvelteKit hooks let you intercept every request. Use hooks.server.ts to add rate limiting across all your email endpoints:

// src/hooks.server.ts
import type { Handle } from "@sveltejs/kit";
 
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
 
function checkRateLimit(key: string, max = 10, windowMs = 60_000): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(key);
 
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
    return true;
  }
 
  if (entry.count >= max) return false;
  entry.count++;
  return true;
}
 
export const handle: Handle = async ({ event, resolve }) => {
  // Rate limit email-related endpoints
  if (event.url.pathname.startsWith("/api/send") || event.url.pathname === "/contact") {
    const ip = event.getClientAddress();
 
    if (!checkRateLimit(ip, 5, 60_000)) {
      return new Response(JSON.stringify({ error: "Too many requests" }), {
        status: 429,
        headers: { "Content-Type": "application/json" },
      });
    }
  }
 
  return resolve(event);
};

This applies to all routes matching the pattern. For production, replace the in-memory map with Redis.

Production Checklist

1. Verify Your Sending Domain

Add DNS records so emails don't land in spam:

RecordTypePurpose
SPFTXTAuthorizes servers to send
DKIMTXTCryptographic signature
DMARCTXTPolicy for failed checks

Our email authentication guide walks through the full DNS setup process.

2. Use $env/static/private

SvelteKit has two env modules:

  • $env/static/private: Compile-time, server-only. Build fails if you import in client code. Use this for API keys.
  • $env/dynamic/private: Runtime, server-only. For values that change between deployments without rebuilding.

Never use $env/static/public or $env/dynamic/public for secrets.

3. Keep Server Code in $lib/server/

src/lib/
  server/
    email.ts           # SDK client
    send-welcome.ts    # Welcome email
    send-receipt.ts    # Receipt email
    send-email-safe.ts # Error wrapper
    emails/
      layout.tsx       # React Email layout
      welcome.tsx      # Welcome template
      receipt.tsx       # Receipt template
  utils.ts             # Client-safe utilities

4. Input Validation with Zod

import { z } from "zod";
import { fail } from "@sveltejs/kit";
 
const contactSchema = z.object({
  email: z.string().email("Invalid email"),
  name: z.string().min(1, "Name is required").max(100),
  message: z.string().min(10, "Message too short").max(5000),
});
 
export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const parsed = contactSchema.safeParse({
      email: formData.get("email"),
      name: formData.get("name"),
      message: formData.get("message"),
    });
 
    if (!parsed.success) {
      return fail(400, {
        errors: parsed.error.flatten().fieldErrors,
      });
    }
 
    // Safe to use parsed.data
    const { email, name, message } = parsed.data;
    // ... send email
  },
} satisfies Actions;

5. Adapter Configuration

SvelteKit uses adapters for deployment. Your email setup works the same regardless of adapter:

# Node.js server (Vercel, Railway, Render, etc.)
npm install @sveltejs/adapter-node
 
# Vercel serverless
npm install @sveltejs/adapter-vercel
 
# Cloudflare Pages
npm install @sveltejs/adapter-cloudflare

If deploying to Cloudflare Pages, note that Node.js APIs like crypto need the nodejs_compat compatibility flag.

Beyond Transactional

A contact form is step one. Production apps need welcome sequences, onboarding emails, marketing campaigns, and subscriber management. Understanding the difference between transactional and marketing email helps you architect the right system.

src/routes/api/subscribe/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { sequenzy } from "$lib/server/email";

export const POST: RequestHandler = async ({ request }) => {
const { email, name } = await request.json();

if (!email) {
  throw error(400, "Email is required");
}

await sequenzy.subscribers.add({
  email,
  attributes: { name },
  tags: ["signed-up"],
});

return json({ success: true });
};

// Sequenzy triggers your welcome sequence automatically
// when the "signed-up" tag is applied. One SDK for
// transactional, marketing, and automations.
src/routes/api/subscribe/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { resend } from "$lib/server/email";
import { RESEND_AUDIENCE_ID } from "$env/static/private";

export const POST: RequestHandler = async ({ request }) => {
const { email, name } = await request.json();

if (!email) {
  throw error(400, "Email is required");
}

const { error: resendError } = await resend.contacts.create({
  audienceId: RESEND_AUDIENCE_ID,
  email,
  firstName: name,
});

if (resendError) {
  throw error(500, resendError.message);
}

return json({ success: true });
};

// Resend supports one-off broadcasts but not automated
// sequences. You'd need another tool for welcome flows.
src/routes/api/subscribe/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { SENDGRID_API_KEY, SENDGRID_LIST_ID } from "$env/static/private";

export const POST: RequestHandler = async ({ request }) => {
const { email, name } = await request.json();

if (!email) {
  throw error(400, "Email is required");
}

const response = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${SENDGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    list_ids: [SENDGRID_LIST_ID],
    contacts: [{ email, first_name: name }],
  }),
});

if (!response.ok) {
  throw error(500, "Failed to subscribe");
}

return json({ success: true });
};

// SendGrid has marketing automation but it's a separate
// plan from the transactional email API.

Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.

FAQ

Can I use Nodemailer with SvelteKit?

Yes, but it's not recommended for production. Nodemailer connects to SMTP servers directly, so you lose delivery analytics, bounce handling, and reputation management. API-based providers handle all that. Nodemailer works for development or internal tools where deliverability doesn't matter.

What's the difference between $env/static/private and $env/dynamic/private?

$env/static/private is replaced at build time. If the variable is missing during build, it fails. $env/dynamic/private reads from process.env at runtime. Use static for API keys that don't change between environments. Use dynamic when the same build runs in staging and production with different env vars.

Should I use form actions or server routes for email?

Form actions (+page.server.ts) for anything triggered by a user form submission, like contact forms and password resets. Server routes (+server.ts) for programmatic endpoints, like webhooks, API calls from other services, or endpoints called via fetch from client-side JavaScript.

Can I use React Email in a SvelteKit project?

Yes. React Email runs server-side only. You install react, react-dom, and @react-email/components, put your templates in $lib/server/emails/, and call render() to convert them to HTML strings. React is only a build dependency for rendering; it never ships to users. Your Svelte frontend is unaffected.

How do I handle Stripe webhooks without body parser issues?

SvelteKit uses web-standard Request objects. Call request.text() to get the raw request body as a string. Pass it directly to stripe.webhooks.constructEvent(). There's no body parser middleware to configure or disable, unlike Express or Fastify.

Does use:enhance affect email sending?

No. use:enhance is a client-side progressive enhancement directive. It upgrades regular HTML form submissions to use fetch instead of full page reloads. Your server-side action function runs the same code either way. The only difference is UX: with use:enhance, the page doesn't reload and you get the action response inline.

How do I send emails from a load function?

Avoid it if possible. Load functions run on every navigation to a route, so an email would send every time someone visits the page. If you must (like email verification via URL), guard it with a one-time-use token:

// src/routes/verify/+page.server.ts
import type { PageServerLoad } from "./$types";
import { redirect } from "@sveltejs/kit";
 
export const load: PageServerLoad = async ({ url }) => {
  const token = url.searchParams.get("token");
  const record = await db.verificationToken.findUnique({ where: { token } });
 
  if (!record || record.used) {
    return { error: "Invalid or expired link" };
  }
 
  await db.verificationToken.update({ where: { token }, data: { used: true } });
  await db.user.update({ where: { id: record.userId }, data: { verified: true } });
 
  throw redirect(303, "/dashboard?verified=true");
};

How do I test emails locally?

Three approaches:

  1. Provider sandbox: Sequenzy, Resend, and SendGrid have test API keys that don't deliver emails.
  2. React Email preview: Run npx react-email dev to preview templates at localhost:3000.
  3. Catch-all inbox: Use Mailpit or Mailtrap to capture outgoing emails in development.

Wrapping Up

  1. Form actions (+page.server.ts) for user-triggered email sends with progressive enhancement
  2. Server routes (+server.ts) for API endpoints and webhooks
  3. $env/static/private blocks API keys from the client at compile time
  4. $lib/server/ directory enforces the server boundary for email modules
  5. React Email works server-side in SvelteKit for maintainable templates
  6. Hooks (hooks.server.ts) for cross-cutting concerns like rate limiting
  7. request.text() gives raw body for Stripe webhook verification

Pick your provider, copy the patterns, and ship.

Frequently Asked Questions

Should I send emails from SvelteKit form actions or API routes?

Use form actions for user-triggered emails (contact forms, feedback). Use API routes (+server.ts) for webhook callbacks and programmatic email sends. Form actions integrate naturally with SvelteKit's progressive enhancement and work without JavaScript.

How do I create an email API endpoint in SvelteKit?

Create a +server.ts file in your routes (e.g., src/routes/api/send-email/+server.ts). Export a POST function that reads the request body, calls your email SDK, and returns a json() response with the appropriate status code.

How do I handle form submissions that send emails in SvelteKit?

Create a +page.server.ts with a named action. Use SvelteKit's <form method="POST" action="?/sendEmail"> and handle the submission in the action function. Access results with $page.form or use:enhance for progressively enhanced submissions.

How do I store email API keys in SvelteKit?

Use private environment variables: define them in .env and access with $env/static/private or $env/dynamic/private. SvelteKit prevents these from being imported in client-side code, keeping your keys secure by default.

Can I use server-side rendering to send emails in SvelteKit?

Don't send emails in load functions—they run on GET requests and should be side-effect-free. Use form actions or API endpoints for email sends. Load functions are for data fetching only.

How do I show loading states during email sends in SvelteKit?

Use use:enhance on your form to get progressive enhancement. Access $page.form for the action result and $navigating for loading state. SvelteKit handles the loading state transitions automatically without manual state management.

How do I validate email form data in SvelteKit?

Use Zod or Valibot to validate formData in your form action. Return validation errors with fail(400, { errors }) and display them in your page component via $page.form.errors. This gives you server-side validation with client-side display.

How do I handle email webhook callbacks in SvelteKit?

Create a +server.ts API route for the webhook endpoint. Read the raw body with await request.text() for signature verification. Verify the signature, parse the payload, and process the event. Return appropriate status codes.

How do I test SvelteKit email endpoints?

Use Vitest with SvelteKit's testing utilities. For API routes, call the exported handler function directly with a mocked RequestEvent. For form actions, test the action function with mocked formData. Mock the email SDK to prevent real sends.

Does SvelteKit's adapter choice affect email sending?

The adapter determines your deployment target but doesn't affect email sending logic. adapter-node gives you a long-running server. adapter-vercel and adapter-cloudflare use serverless functions with execution time limits. Your email code works the same—just be aware of timeout limits for bulk operations.