Fast jede Next.js-App muss ein paar E-Mails verschicken: eine Welcome-Mail nach dem Signup, einen Magic Link oder Passwort-Reset, einen Beleg. Der App Router bietet dir dafür mehrere Stellen an, und die verhalten sich ziemlich unterschiedlich, wenn etwas langsam ist oder schiefgeht.
In diesem Artikel geht es darum, wo der Versand hingehört, wie der API-Key nicht im Client landet, wie deine User nicht auf deinen E-Mail-Provider warten müssen und welche Fehler zu fehlenden oder doppelten Mails führen. Die Beispiele nutzen einen generischen sendEmail()-Helper, damit du jeden beliebigen Provider einsetzen kannst.
1. Der Versand gehört immer auf den Server
Mit dem API-Key deines E-Mail-Providers kann man Mails im Namen deiner Domain verschicken. Landet er im Browser, kann ihn jeder benutzen.
- Lies ihn aus
process.env.EMAIL_API_KEY(ohneNEXT_PUBLIC_-Präfix, sonst wird er in die Client-Bundles eingebaut). - Leg den Helper in ein Modul, das nur Server-Code importiert, und markiere es so, dass ein versehentlicher Client-Import den Build fehlschlagen lässt:
// lib/email.ts import "server-only"; type SendEmailInput = { to: string; subject: string; html: string; idempotencyKey?: string; }; export async function sendEmail(input: SendEmailInput) { const res = await fetch("https://api.your-email-provider.com/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.EMAIL_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(input), }); if (!res.ok) { throw new Error(`Email send failed: ${res.status} ${await res.text()}`); } return res.json(); } Das Paket server-only (npm i server-only) sorgt dafür, dass der Build fehlschlägt, sobald eine Client Component diese Datei importiert.
2. Der richtige Einstiegspunkt
| Auslöser | Von wo du sendest |
|---|---|
| Ein Formular in deiner eigenen UI (Signup, Teammitglied einladen, Kontaktformular) | Eine Server Action |
| Ein externes System ruft dich auf (Stripe-Webhook, Hook deines Auth-Providers, Cron) | Ein Route Handler (app/api/.../route.ts) |
| Etwas, das später passiert (Trial endet in 3 Tagen, wöchentlicher Digest) | Eine Queue oder ein geplanter Job, kein Request-Handler |
Server Action: Teammitglied einladen
// app/team/actions.ts "use server"; import { after } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { sendEmail } from "@/lib/email"; import { renderInviteEmail } from "@/emails/invite"; export async function inviteTeammate(formData: FormData) { const session = await auth(); if (!session) throw new Error("Not signed in"); const email = String(formData.get("email") ?? "").trim().toLowerCase(); if (!email.includes("@")) return { error: "Enter a valid email" }; const invite = await db.invite.create({ data: { email, teamId: session.teamId, invitedBy: session.userId }, }); // Dem User sofort antworten; die E-Mail erst senden, wenn die Response raus ist. after(async () => { await sendEmail({ to: email, subject: `${session.name} invited you to ${session.teamName}`, html: await renderInviteEmail({ invite, inviter: session.name }), idempotencyKey: `invite-${invite.id}`, }); }); return { ok: true }; } Zwei Dinge sind hier wichtig:
- Die Autorisierung passiert in der Action selbst. Server Actions sind öffentliche HTTP-Endpunkte. Jeder kann sie mit beliebigen Formulardaten aufrufen. Prüf also die Session und validiere den Input direkt in der Action, nicht nur in der UI.
-
after()(ausnext/server, stabil seit Next.js 15.1) führt den Callback aus, sobald die Response verschickt wurde. Der User wartet nicht auf die Latenz deines E-Mail-Providers, und ein langsamer Provider hält das Formular nicht im Pending-Zustand fest.
Route Handler: ein Webhook von deinem Auth-Provider
// app/api/webhooks/auth/route.ts import { sendEmail } from "@/lib/email"; import { renderWelcomeEmail } from "@/emails/welcome"; import { verifyAuthWebhook } from "@/lib/auth-webhooks"; export async function POST(request: Request) { const payload = await verifyAuthWebhook(request); // wirft bei ungültiger Signatur if (payload.type !== "user.created") { return Response.json({ ignored: true }); } await sendEmail({ to: payload.user.email, subject: "Welcome to Acme", html: await renderWelcomeEmail({ name: payload.user.firstName }), idempotencyKey: `welcome-${payload.user.id}`, }); return Response.json({ ok: true }); } Webhook-Absender versuchen es erneut, wenn du einen Fehler zurückgibst oder in einen Timeout läufst. Gut für die Zuverlässigkeit, schlecht im Hinblick auf doppelte Mails. Genau dafür ist der idempotencyKey da (Abschnitt 5).
3. Kein Versand aus Client Components oder useEffect
Es ist verlockend, nach einem erfolgreichen Signup vom Client aus einen "Welcome-Mail senden"-Request abzufeuern. Lass es:
- Der Browser-Tab kann geschlossen werden, bevor der Request rausgeht.
- React Strict Mode führt Effects in der Entwicklung doppelt aus. Du siehst lokal also doppelte Mails und "fixt" am Ende das Falsche.
- Du brauchst dafür einen öffentlichen Endpunkt, über den dich jeder dazu bringen kann, E-Mails zu verschicken.
Sende aus dem Server-Code, der den User angelegt hat, oder aus dem Webhook, den dein Auth-Provider aufruft.
4. Templates: React Email oder schlichtes HTML
E-Mail-Clients sind keine Browser. Outlook unter Windows rendert immer noch mit der Engine von Word, Gmail entfernt in manchen Kontexten <style>-Blöcke, und auf Flexbox und Grid ist kein Verlass. Zwei vernünftige Optionen:
- React Email (
@react-email/components+@react-email/render): Du schreibst Templates als React-Komponenten mit E-Mail-tauglichen Bausteinen (Section,Row,Column,Button) und renderst sie auf dem Server zu einem HTML-String. - Templates beim Provider: Du gestaltest die Mail im Editor deines Providers und schickst nur eine Template-ID plus Variablen. Dann können auch Nicht-Entwickler die Texte ohne Deploy ändern.
// emails/welcome.tsx import { Html, Body, Container, Heading, Text, Button } from "@react-email/components"; import { render } from "@react-email/render"; function WelcomeEmail({ name }: { name: string }) { return ( <Html> <Body style={{ fontFamily: "Arial, sans-serif", backgroundColor: "#ffffff" }}> <Container style={{ maxWidth: 560, padding: 24 }}> <Heading as="h1">Welcome, {name}</Heading> <Text>Your workspace is ready. The fastest way to get value is to connect your data first.</Text> <Button href="https://app.acme.com/onboarding" style={{ backgroundColor: "#111", color: "#fff", padding: "12px 20px" }}> Finish setup </Button> </Container> </Body> </Html> ); } export function renderWelcomeEmail(props: { name: string }) { return render(<WelcomeEmail {...props} />); // liefert ein Promise<string> } Egal wofür du dich entscheidest: Schick zusätzlich eine Plain-Text-Version mit, falls dein Provider keine erzeugt, und pack die wichtige Aktion in einen echten Link statt nur in ein Bild.
5. Sends idempotent machen
Duplikate entstehen durch Retries: ein Webhook, der zweimal zugestellt wird, ein User, der ein Formular doppelt abschickt, eine Serverless Function, die nach einem Timeout erneut läuft. Die Lösung: Jede logische E-Mail bekommt einen stabilen Key.
-
welcome-${userId},invite-${inviteId},receipt-${invoiceId},password-reset-${tokenId}
Diesen Key gibst du entweder als Idempotency-Key an deinen Provider weiter (viele APIs akzeptieren einen und liefern bei einem wiederholten Key das ursprüngliche Ergebnis zurück), oder du speicherst ihn in deiner Datenbank mit einem Unique Constraint und überspringst den Versand, wenn der Insert kollidiert:
const inserted = await db.sentEmail.createMany({ data: [{ key: `welcome-${user.id}` }], skipDuplicates: true, }); if (inserted.count === 0) return; // bereits gesendet 6. Was after() nicht kann
after() ist super für "der User soll nicht warten müssen". Eine Job-Queue ist es aber nicht:
- Wenn der Versand eine Exception wirft, versucht es niemand erneut. Logge den Fehler und richte für wichtige Mails einen Alert ein.
- Auf Serverless-Plattformen hängt die Arbeit an der Lebensdauer dieser einen Invocation. Kurze Sends sind kein Problem, lange Arbeit gehört woanders hin.
- Es kann nichts für später einplanen.
Für Mails, die ankommen müssen (Passwort-Resets, Belege), baust du entweder einen Retry mit kurzem Backoff in den Callback ein, oder du legst einen Job in eine Queue (BullMQ, Inngest, QStash, Trigger.dev, das Scheduling deines Providers) und lässt die Queue die Retries übernehmen. Für alles Zeitbasierte (Trial-Reminder, Onboarding-Drips) nimmst du einen Scheduler oder die Automatisierung einer E-Mail-Plattform statt Request-Handlern.
7. Bevor du live gehst
- [ ] Versand von deiner eigenen Domain mit eingerichtetem SPF, DKIM und DMARC, nicht von der geteilten Testdomain des Providers
- [ ] Getrennte Versand-Subdomains für Transaktions- und Marketing-Mails, falls du beides verschickst
- [ ] API-Key nur in serverseitigen Env-Variablen,
server-onlyim E-Mail-Modul - [ ] Server Actions prüfen die Session und validieren den Input
- [ ] Jede E-Mail hat einen Idempotency-Key
- [ ] Fehler werden geloggt und lösen Alerts aus, statt still verschluckt zu werden
- [ ] Rendering in Gmail, Outlook und Apple Mail testen (oder mit einem Preview-Tool für E-Mail-Rendering)
Das Ganze mit Sequenzy
So sieht das mit Sequenzy aus, einer E-Mail-Plattform für SaaS. Der sendEmail-Helper von oben wird zu:
import "server-only"; import Sequenzy from "sequenzy"; const sequenzy = new Sequenzy({ apiKey: process.env.SEQUENZY_API_KEY }); export function sendWelcome(to: string, firstName: string) { return sequenzy.transactional.send({ to, slug: "welcome-email", // im Dashboard gestaltetes Template variables: { firstName }, }); } Über denselben Account laufen auch die Onboarding- und Trial-Sequenzen aus Abschnitt 6, zeitbasierte Mails brauchen also keine eigene Queue. Der Quickstart dauert etwa fünf Minuten. Alles andere in diesem Artikel gilt unabhängig davon, für welchen Provider du dich entscheidest.
