Ein Formular an deine E-Mail senden

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.

Preet SutharGründer, Ikiform

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:

Was eine Server Action tut, wenn sie die E-Mail selbst sendet und wenn sie den Post weiterleitetselbst sendenweiterleitendeinFormularuseActionStatedeineActionvalidiert,prüft denHoneypotResendoderSMTPdeineverifizierteDomainnurPostfachkeineKopie,falls sieverlorengehtdeinFormularuseActionStatedeineActionfügt denSecret KeyhinzuIkiformvalidiert,bewertet,speichertPostfachundDashboardplusWebhooksFormDataAPI-SchlüsselFormDataJSONWas eine Server Action tut, wenn sie die E-Mail selbst sendet und wenn sie den Post weiterleitetselbst sendenweiterleitendein FormularuseActionStatedeine Actionvalidiert, prüft den HoneypotResend oder SMTPdeine verifizierte Domainnur Postfachkeine Kopie, falls sie verlorengehtdein FormularuseActionStatedeine Actionfügt den Secret Key hinzuIkiformvalidiert, bewertet, speichertPostfach und Dashboardplus WebhooksFormDataAPI-SchlüsselFormDataJSON
  1. 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.
  2. 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.
Beide halten jeden Schlüssel auf dem Server. Der Unterschied ist, wie viel vom mittleren Kasten du selbst schreibst und pflegst.
AufgabeServer Action mit Resend oder NodemailerServer Action mit Ikiform
ValidierungZod in der ActionDie Felddefinition des Formulars, durchgesetzt auf Seiten von Ikiform
SpamDein Honeypot; alles Weitere musst du selbst hinzufügenHoneypot, Spam-Bewertung und eine optionale Menschlichkeitsprüfung
Rate-LimitsKeins, bis du einen Speicher wie Redis hinzufügstEingebaut, pro Besucher und pro Formular
AbsenderdomainVerifiziere deine mit SPF- und DKIM-EinträgenKeine: Mail kommt von notifications@ikiform.com
Betreff und DesignAlles, was du willstFest: New submission: <form name>
Bestätigung an den BesucherEin weiterer send-AufrufNicht verfügbar; sende sie aus deinem E-Mail-Tool
Eine Kopie jeder NachrichtNur wenn du eine Datenbank hinzufügstDashboard, 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.

app/inquiry/state.ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
app/inquiry/inquiry-form.tsx
"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.
  • useFormStatus liest 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.
  • replyTo in der Resend-Version und das Feld namens email in 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 sendMail mit from, to, replyTo, subject und text auf.
  • Definiere für die Ikiform-Version die fünf Felder im Tab Fields des Formulars, mit budget als select, damit ein ungültiger Wert 422 validation_failed mit einer Meldung pro Feld bekommt.
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte 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

  1. Sende mit leerem Budget ab. Der Fehler erscheint unter dem Select, und sonst geht nichts verloren.
  2. Sende eine echte Anfrage ab. Die Erfolgszeile ersetzt das Formular.
  3. Öffne die E-Mail und klick auf Reply. In der Zeile To steht die Adresse, die du eingegeben hast.
  4. Füll den versteckten Honeypot mit den Entwicklertools des Browsers aus und sende ab. Du siehst Erfolg, und keine E-Mail kommt an.
  5. Ö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 auf html umstellst, escape vorher jeden Wert, sonst kann ein Besucher Markup und Links in dein Postfach schleusen.
  • Header-Injection. Setze niemals einen rohen Formularwert in from oder to. replyTo ist mit Resend sicher, weil Zod bereits geprüft hat, dass es eine E-Mail-Adresse ist.
  • Umgebungsvariablen. RESEND_API_KEY und IKIFORM_SECRET_KEY dürfen nicht mit NEXT_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 idempotencyKey von Resend oder der Header Idempotency-Key von 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

ProblemWas zu tun ist
Resend antwortet, dass die Domain nicht verifiziert istFü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_limitedEin 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_failedDer 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 ProduktionDer Schlüssel fehlt in den Umgebungsvariablen des Deployments. Füge ihn der Produktionsumgebung hinzu und deploye neu.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.