# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/send-form-to-email/nextjs

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:

Diagramm: Was eine Server Action tut, wenn sie die E-Mail selbst sendet und wenn sie den Post weiterleitet

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.

| 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.

`app/inquiry/state.ts`:

```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`:

```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>
  );
}
```

**Resend** (`app/inquiry/actions.ts`)

```ts
"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: {} };
}
```

**Ikiform** (`app/inquiry/actions.ts`)

```ts
"use server";

import { headers } from "next/headers";
import type { InquiryState } from "./state";

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      name: formData.get("name"),
      email: formData.get("email"),
      company: formData.get("company"),
      budget: formData.get("budget"),
      message: formData.get("message"),
      _gotcha: formData.get("_gotcha") ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, errors: {} };

  const errors: InquiryState["errors"] = {};
  for (const [field, messages] of Object.entries<string[]>(result?.error?.fields ?? {})) {
    errors[field as keyof InquiryState["errors"]] = messages[0];
  }
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your inquiry. Try again.";
  return { sent: false, 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](https://ikiform.com/de/docs/rate-limits.md) 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](https://ikiform.com/de/docs/guides/nextjs.md) 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

| 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

### Funktioniert Nodemailer in einer Server Action?

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.

### Wie sende ich eine E-Mail aus einem Next.js-Formular?

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.

### Ist es sicher, Resend aus einer Server Action aufzurufen?

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.

### Brauche ich eine Datenbank für ein Next.js-Kontaktformular?

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.

### Kann Ikiform dem Besucher eine Bestätigungs-E-Mail schicken?

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.

## Weitere Anleitungen

- [Ein Formular an deine E-Mail senden](https://ikiform.com/de/guides/send-form-to-email.md): Bekomm jede Formulareinsendung ohne Mailserver in dein Postfach: warum mailto: und PHPs mail() scheitern und ein Setup, das Spam filtert und dich antworten lässt.
- [Alternative zu EmailJS](https://ikiform.com/de/guides/send-form-to-email/emailjs-alternative.md): Lös ein Kontaktformular von EmailJS: warum E-Mail-Schlüssel im Browser ein Risiko sind, was sich im Code ändert und worauf du verzichtest, etwa die automatische Antwort.
- [Mehrere Empfänger](https://ikiform.com/de/guides/send-form-to-email/multiple-recipients.md): Mail ein Formular an dein ganzes Team oder leite jede Nachricht nach Thema an die richtige Person, ohne dass das Formular selbst bestimmt, wer die Mail bekommt.
- [Aus deinem Postfach antworten](https://ikiform.com/de/guides/send-form-to-email/reply-to.md): Sorg dafür, dass Antworten auf eine Formular-Benachrichtigung an die Person gehen, die das Formular ausgefüllt hat: welche Feldnamen funktionieren, wann _replyto nötig ist und warum Antworten fehlgehen.
- [Ohne PHP](https://ikiform.com/de/guides/send-form-to-email/without-php.md): Ersetze ein contact.php-Skript, das mail() aufruft, durch ein Formular, das an einen gehosteten Endpunkt sendet, mit deinen Feldnamen, deiner Dankeseite und Antworten.
- [An Gmail](https://ikiform.com/de/guides/send-form-to-email/gmail.md): Bekomm Formulareinsendungen in Gmail ohne mailto:-Links, App-Passwörter oder Apps Script, dann beschrifte sie, halte sie aus dem Spam heraus und antworte aus Gmail.
