Ein Formular an deine E-Mail senden6 Min. Lesezeit
Ein Next.js-Kontaktformular, das E-Mails sendet
Zwei Wege, ein Next.js-Formular per Server Action zu mailen: selbst mit Resend oder Nodemailer senden oder an ein Formular-Backend weiterleiten, ehrlich verglichen.
Im App Router empfängt eine Server Action die FormData des Formulars auf dem Server, wo ein E-Mail-API-Schlüssel sicher ist. Du kannst die E-Mail selbst mit Resend oder Nodemailer senden oder den Post an ein Formular-Backend weiterleiten, das Spam filtert, ihn speichert und dir eine E-Mail schickt. Selbst senden gibt dir volle Kontrolle über die E-Mail; weiterleiten erspart dir Spamfilter, Rate-Limits und Speicherung, die du sonst bauen müsstest.
Was hier anders ist
Next.js hat bereits einen Server, der übliche Grund für ein Formular-Backend, „meine Site kann keinen Code ausführen“, fällt also weg. Die Frage ist, was dieser Code leisten muss. Die E-Mail zu senden, ist der einfache Teil. Alles drumherum kostet die Zeit:
- Selbst senden: Die Action validiert die Felder, prüft den Honeypot und ruft deinen E-Mail-Anbieter mit einem API-Schlüssel auf. Die Nachricht existiert nur als diese E-Mail.
- Weiterleiten: Die Action sendet die Felder mit deinem Secret Key an Ikiform. Ikiform validiert gegen die Definition des Formulars, bewertet Spam, speichert die Einsendung und mailt die Empfänger.
| Aufgabe | Server Action mit Resend oder Nodemailer | Server Action mit Ikiform |
|---|---|---|
| Validierung | Zod in der Action | Die Felddefinition des Formulars, durchgesetzt auf Seiten von Ikiform |
| Spam | Dein Honeypot; alles Weitere musst du selbst hinzufügen | Honeypot, Spam-Bewertung und eine optionale Menschlichkeitsprüfung |
| Rate-Limits | Keins, bis du einen Speicher wie Redis hinzufügst | Eingebaut, pro Besucher und pro Formular |
| Absenderdomain | Verifiziere deine mit SPF- und DKIM-Einträgen | Keine: Mail kommt von notifications@ikiform.com |
| Betreff und Design | Alles, was du willst | Fest: New submission: <form name> |
| Bestätigung an den Besucher | Ein weiterer send-Aufruf | Nicht verfügbar; sende sie aus deinem E-Mail-Tool |
| Eine Kopie jeder Nachricht | Nur wenn du eine Datenbank hinzufügst | Dashboard, CSV-Export und API |
Wähle die Do-it-yourself-Version für eine gebrandete E-Mail oder eine Bestätigung an den Besucher, wenn du Spam und Speicherung gern selbst verantwortest. Wähle das Weiterleiten, um nicht mehr über das Formular nachdenken zu müssen, sobald es funktioniert.
Beide Versionen
Das Formular ist eine Projektanfrage: Name, geschäftliche E-Mail, Firma, eine Budgetspanne und eine Beschreibung. Der State-Typ und die Client-Komponente sind gemeinsam; nur actions.ts ändert sich.
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}- Der State-Typ liegt in einer eigenen Datei, weil eine
"use server"-Datei nur async-Funktionen exportieren darf. useFormStatusliest den Pending-Zustand des Formulars, in dem es steht, daher hat der Button eine eigene Komponente.- In der Resend-Version gibt ein ausgefüllter Honeypot Erfolg zurück, ohne zu senden, der Bot lernt also nichts. Ikiform macht dasselbe, wenn es den Post als Spam speichert.
replyToin der Resend-Version und das Feld namensemailin der Ikiform-Version sorgen beide dafür, dass Reply an den Besucher schreibt.- Nodemailer funktioniert genauso wie Resend: Erstell einen Transport mit deinen SMTP-Daten und ruf
sendMailmitfrom,to,replyTo,subjectundtextauf. - Definiere für die Ikiform-Version die fünf Felder im Tab Fields des Formulars, mit
budgetalsselect, damit ein ungültiger Wert422 validation_failedmit einer Meldung pro Feld bekommt.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen. Jedes Server-Beispiel reicht den Origin-Header des Browsers weiter, falls vorhanden, weil das eigene fetch eines Servers keinen sendet und Ikiform kostenlose localhost-Testeinsendungen daran erkennt.Es gibt eine dritte Option: Lass die Action weg und lass den Browser direkt an Ikiform senden. Es ist kein Secret Key beteiligt, daher gelten das Standard-Rate-Limit und Allowed domains pro Besucher. Die Next.js-Anleitung in der Dokumentation zeigt die Client-Komponente.
Prüfen, ob es funktioniert
- Sende mit leerem Budget ab. Der Fehler erscheint unter dem Select, und sonst geht nichts verloren.
- Sende eine echte Anfrage ab. Die Erfolgszeile ersetzt das Formular.
- Öffne die E-Mail und klick auf Reply. In der Zeile To steht die Adresse, die du eingegeben hast.
- Füll den versteckten Honeypot mit den Entwicklertools des Browsers aus und sende ab. Du siehst Erfolg, und keine E-Mail kommt an.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast. Dieser Schritt gilt nur für die Ikiform-Version.
Stolperfallen
- Escaping in HTML-E-Mails. Die Resend-Version sendet
text. Wenn du aufhtmlumstellst, escape vorher jeden Wert, sonst kann ein Besucher Markup und Links in dein Postfach schleusen. - Header-Injection. Setze niemals einen rohen Formularwert in
fromoderto.replyToist mit Resend sicher, weil Zod bereits geprüft hat, dass es eine E-Mail-Adresse ist. - Umgebungsvariablen.
RESEND_API_KEYundIKIFORM_SECRET_KEYdürfen nicht mitNEXT_PUBLIC_beginnen, sonst liefert Next.js sie an den Browser aus. - Doppelter Versand. Ein Doppelklick kann die Action zweimal ausführen. Der Pending-Zustand deaktiviert den Button, aber ein langsames Netz kann trotzdem eine Race Condition erzeugen; die Option
idempotencyKeyvon Resend oder der HeaderIdempotency-Keyvon Ikiform macht eine Wiederholung wirkungslos. - Server Actions sind öffentliche Endpunkte. Jeder kann sie direkt aufrufen, deshalb müssen Validierung und Spamprüfungen darin laufen, nicht nur im Formular.
Wenn etwas schiefgeht
| Problem | Was zu tun ist |
|---|---|
| Resend antwortet, dass die Domain nicht verifiziert ist | Füge die DNS-Einträge von Resend für die Domain in from hinzu oder sende während der Entwicklung von der Testadresse von Resend. |
429 rate_limited | Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte Retry-After ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden. |
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
| Die Action funktioniert lokal, aber nicht in der Produktion | Der Schlüssel fehlt in den Umgebungsvariablen des Deployments. Füge ihn der Produktionsumgebung hinzu und deploye neu. |
Fragen
Ja, in der Node.js-Runtime, die Server Actions standardmäßig nutzen. Es öffnet eine SMTP-Verbindung, läuft also nicht in der Edge-Runtime, und manche Hosts beschränken ausgehendes SMTP. Eine HTTP-E-Mail-API wie Resend umgeht beides.
Verarbeite das Formular mit einer Server Action, die auf dem Server läuft. Ruf darin entweder eine E-Mail-API wie Resend oder Nodemailer auf oder leite die Felder an ein Formular-Backend weiter, das dir eine E-Mail schickt.
Ja, der API-Schlüssel bleibt auf dem Server, solange sein Name nicht mit NEXT_PUBLIC_ beginnt. Validiere jedes Feld in der Action, weil Server Actions direkt aufgerufen werden können.
Nicht zum Senden von E-Mails. Du brauchst eine, wenn du einen Nachweis der Nachrichten willst, der eine verlorene E-Mail überdauert. Ein Formular-Backend führt diesen Nachweis für dich.
Nein. Ikiform schickt nur dir und deinem Team E-Mails. Sende eine Bestätigung über deinen eigenen E-Mail-Anbieter, in deiner Server Action oder per Webhook.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.