How to Send Emails in SvelteKit (2026 Guide)
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
Add your API key to .env:
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.
$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.
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:enhanceupgrades 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 Actionsgives 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-domCreate 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:
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.
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).
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
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.
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
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:
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy 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-cloudflareIf 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.
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:
- Provider sandbox: Sequenzy, Resend, and SendGrid have test API keys that don't deliver emails.
- React Email preview: Run
npx react-email devto preview templates atlocalhost:3000. - Catch-all inbox: Use Mailpit or Mailtrap to capture outgoing emails in development.
Wrapping Up
- Form actions (
+page.server.ts) for user-triggered email sends with progressive enhancement - Server routes (
+server.ts) for API endpoints and webhooks $env/static/privateblocks API keys from the client at compile time$lib/server/directory enforces the server boundary for email modules- React Email works server-side in SvelteKit for maintainable templates
- Hooks (
hooks.server.ts) for cross-cutting concerns like rate limiting 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.