# Nextjs Contact Form: E-Mail senden und Spam blocken

> So baust du ein nextjs contact form im App Router: zod-Validierung, eine Server Action, die die E-Mail sendet, Honeypot, Rate Limit und eine kostenlose Vorlage.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 5. Nov. 2026
- Kategorie: [Praxis: Screens und Apps](https://wingo-ui.com/de/blog/category/build)
- Lesezeit: 10 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/nextjs-contact-form
- Englische Fassung: https://wingo-ui.com/blog/nextjs-contact-form

## Kurz gesagt

Ein Kontaktformular in Next.js braucht im App Router ein zod-Schema, eine Server Action, die die Formulardaten validiert und die E-Mail sendet, und ein Client-Formular, das die Fehler mit useActionState anzeigt. Bevor die Seite live geht, kommen ein verstecktes Honeypot-Feld und ein Rate Limit pro IP dazu, und die Action gibt die eingegebenen Werte zurück, damit React 19 das Formular bei einem Fehler nicht leert. Wenn du statt eines nackten Formulars die ganze Seite willst: Die Vorlage Contact Page von Wingo UI ist kostenlos und nimmt dieselbe Server Action über ihren Prop onSubmit entgegen.

Ein Nextjs Contact Form sieht nach zehn Minuten Arbeit aus: drei Felder, ein Button, eine E-Mail im Postfach. Dann kommt die erste Spamwelle, ein Besucher verliert wegen eines Validierungsfehlers eine lange Nachricht, und du merkst, dass dein SMTP-Passwort nur einen Import vom Client-Bundle entfernt war. Dieses Tutorial baut ein Next.js-Kontaktformular im App Router von Next.js 16, das mit zod validiert, die E-Mail aus einer Server Action sendet, Bots per Honeypot aussortiert, Anfragen pro IP begrenzt und behält, was der Besucher getippt hat, wenn etwas schiefgeht. Zum Schluss ersetzt die kostenlose Vorlage Contact Page das nackte Formular.

## Was braucht ein Next.js-Kontaktformular?

Fünf Teile. Die ersten drei ergeben ein funktionierendes Formular, die letzten zwei entscheiden, ob es echte Besucher übersteht:

1. **Ein Schema**, das festlegt, was eine gültige Nachricht ist, und das jede Stelle nutzt, die das Formular liest.
2. **Eine Server Action**, die die Formulardaten parst, die E-Mail sendet und Feldfehler zurückgibt.
3. **Ein Client-Formular** mit Labels, Fehlern direkt am Feld, einem Button mit Ladezustand und einer Bestätigung.
4. **Spamschutz**, der keine Menschen bestraft: ein Honeypot und ein Rate Limit.
5. **Fehlertoleranz**: Der Text des Besuchers übersteht einen gescheiterten Versand oder einen Validierungsfehler.

Der eigentliche E-Mail-Versand, also der Schritt, den Leute mit „nextjs contact form send email“ suchen, ist etwa zehn Zeilen lang. Die Teile 4 und 5 brauchen mehr Überlegung, und hinter Teil 5 steckt ein Verhalten von React 19, das das Formular nach einem gescheiterten Versand leert.

## Soll ich eine Server Action oder eine API-Route nehmen?

Nimm eine Server Action, solange nichts außer deinem eigenen Formular den Endpunkt braucht. Mit einer Action schickt `<form action={...}>` die Daten ohne `fetch`-Aufruf an den Server, das Formular funktioniert schon, bevor JavaScript geladen ist, und `useActionState` gibt das Ergebnis direkt an die Komponente zurück. Der [Forms-Guide von Next.js](https://nextjs.org/docs/app/guides/forms) nutzt genau dieses Muster mit zod für die Validierung (geprüft am 5. November 2026).

Ein Route Handler (`app/api/contact/route.ts`) ist weiterhin die bessere Wahl, wenn eine Mobile App, ein Webhook oder ein Formular-Builder von Drittanbietern an denselben Endpunkt sendet. In beiden Fällen bleiben deine E-Mail-Zugangsdaten auf dem Server. Eine Warnung gilt für beide: Eine Action ist ein öffentlicher HTTP-Endpunkt. Jeder kann sie mit beliebigen Daten aufrufen, also validierst du auf dem Server, auch wenn der Client schon validiert.

## Wie validiere ich ein Kontaktformular mit zod?

Leg das Schema in eine eigene Datei ohne `"use server"`, damit die Action und jeder Client-Code es importieren können. Trimm die Werte vor der Prüfung, begrenz jede Länge und lehn Zeilenumbrüche im Namen ab, denn der landet im Betreff einer E-Mail.

```ts
// app/contact/schema.ts
import { z } from "zod"

export const contactSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "Gib deinen Namen ein.")
    .max(100, "Der Name darf höchstens 100 Zeichen lang sein.")
    .refine((value) => !/[\r\n]/.test(value), "Schreib den Namen in eine Zeile."),
  email: z
    .string()
    .trim()
    .toLowerCase()
    .pipe(z.email("Gib eine gültige E-Mail-Adresse ein, zum Beispiel emma@northwind.com.")),
  message: z
    .string()
    .trim()
    .min(20, "Erzähl uns etwas mehr, mindestens 20 Zeichen.")
    .max(2000, "Die Nachricht darf höchstens 2.000 Zeichen lang sein."),
})

export type ContactInput = z.infer<typeof contactSchema>
```

Das ist zod 4 (die aktuelle Version im November 2026): `z.email()` ist ein eigenständiges Schema auf oberster Ebene, und `z.flattenError()` macht aus einem gescheiterten Parse `{ fieldErrors: { name?: string[], ... } }`, was sich eins zu eins auf die Formularfelder abbilden lässt.

## Wie sende ich E-Mails aus einem Next.js-Kontaktformular?

Jeder Anbieter funktioniert. Dieses Beispiel nutzt [Nodemailer](https://nodemailer.com/) mit normalem SMTP (`npm install nodemailer` und `npm install -D @types/nodemailer`), es funktioniert also mit deinem eigenen Mailserver, Amazon SES, Postmark oder alles andere, was SMTP spricht. Der Import von `server-only` sorgt dafür, dass jeder Versuch, diese Datei in eine Client-Komponente zu ziehen, den Build abbricht.

```ts
// app/contact/mailer.ts
import "server-only"
import nodemailer from "nodemailer"
import type { ContactInput } from "./schema"

const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT ?? 587),
  secure: process.env.SMTP_PORT === "465",
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
})

export async function sendContactMail({ name, email, message }: ContactInput) {
  await transport.sendMail({
    // deine eigene verifizierte Adresse; die des Besuchers kommt in replyTo
    from: `"Kontaktformular" <${process.env.CONTACT_FROM}>`,
    to: process.env.CONTACT_TO,
    replyTo: { name, address: email },
    subject: `Neue Nachricht von ${name}`,
    text: `${message}\n\nVon: ${name} <${email}>`,
  })
}
```

Setz die Adresse des Besuchers nie in `from`. Die SPF- und DKIM-Einträge deiner Domain decken sie nicht ab, die DMARC-Ausrichtung schlägt fehl, und die Nachricht landet im Spam oder wird abgelehnt. Mit `replyTo` antwortest du genauso mit einem Klick.

### Wie halte ich Spam mit einem Honeypot fern?

Ein Honeypot ist ein Textfeld, das Menschen nie sehen und einfache Bots ausfüllen, weil sie schlicht alles ausfüllen. Hat es einen Wert, tut die Action so, als hätte alles geklappt, und sendet nichts. Das Vortäuschen ist wichtig: Ein Bot, der einen Fehler sieht, versucht es mit anderen Eingaben noch einmal.

### Wie baue ich ein Rate Limit für eine Server Action?

Zähl die Anfragen pro IP in einem gleitenden Zeitfenster. Die Version unten hält die Zähler im Arbeitsspeicher, und das reicht für einen einzelnen, dauerhaft laufenden Node-Server. Läuft die App serverless oder auf mehreren Instanzen, hat jede Kopie ihren eigenen Speicher. Dann gehört der Zähler nach Redis oder in deine Datenbank, zum Beispiel mit dem Paket `@upstash/ratelimit` von Upstash.

```ts
// app/contact/rate-limit.ts
import "server-only"

const hits = new Map<string, number[]>()

export function rateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now()
  if (hits.size > 10_000) hits.clear() // hält den Speicher begrenzt
  const recent = (hits.get(key) ?? []).filter((time) => now - time < windowMs)
  const allowed = recent.length < limit
  if (allowed) recent.push(now)
  hits.set(key, recent)
  return allowed
}
```

### Wie setze ich alles in einer Server Action zusammen?

Eine Action verbindet die Teile: zuerst der Honeypot, dann das Rate Limit, dann die Validierung, dann der Versand. Jeder Fehlerfall gibt die eingegebenen Werte zurück. Warum, erklärt der nächste Abschnitt.

```ts
// app/contact/actions.ts
"use server"

import { headers } from "next/headers"
import { z } from "zod"
import { sendContactMail } from "./mailer"
import { rateLimit } from "./rate-limit"
import { contactSchema } from "./schema"

type Values = { name: string; email: string; message: string }

export type ContactState = {
  status: "idle" | "sent" | "invalid" | "error"
  values?: Values
  errors?: Partial<Record<keyof Values, string[]>>
  message?: string
}

export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
  const values: Values = {
    name: String(formData.get("name") ?? ""),
    email: String(formData.get("email") ?? ""),
    message: String(formData.get("message") ?? ""),
  }

  // Menschen sehen dieses Feld nie, Bots füllen es aus
  if (formData.get("website")) return { status: "sent" }

  // hinter einem Proxy, den du kontrollierst, ist die erste Adresse der Client
  const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"
  if (!rateLimit(`contact:${ip}`, 5, 10 * 60_000)) {
    return { status: "error", values, message: "Zu viele Nachrichten. Versuch es in ein paar Minuten noch einmal." }
  }

  const parsed = contactSchema.safeParse(values)
  if (!parsed.success) {
    return { status: "invalid", values, errors: z.flattenError(parsed.error).fieldErrors }
  }

  try {
    await sendContactMail(parsed.data)
  } catch {
    return { status: "error", values, message: "Deine Nachricht ist nicht angekommen. Versuch es noch einmal." }
  }
  return { status: "sent" }
}
```

Vertrau `x-forwarded-for` nur, wenn dein Hoster oder Reverse Proxy den Header setzt; an einen nackten Node-Server kann jeder diesen Header direkt schicken.

## Wie baue ich das Formular mit React und Tailwind?

Das Formular ist eine Client-Komponente, die `useActionState` aufruft. Diesen Schritt suchen Leute mit „react contact form tailwind“: Klassen aus Tailwind v4 für das Layout und Komponenten, die das Styling der Felder schon mitbringen. Für die Felder nutzt das Beispiel [Input](https://wingo-ui.com/components/input) und [Textarea](https://wingo-ui.com/components/textarea) von Wingo UI. Beide rendern über die gemeinsame Anatomie von [Field](https://wingo-ui.com/components/field) das Label, die Beschreibung und unter dem Feld einen animierten Fehler, und die ids und `aria-invalid` sind schon verdrahtet. Der [Toast](https://wingo-ui.com/components/toast) bestätigt den Versand, ohne das Layout zu verschieben.

```bash
npx wingo-ui@latest add input textarea button toast
```

So verhalten sich die Fehler im Formular. Drück „Continue“ mit einem zu kurzen Titel und sieh zu, wie die Meldung den Hinweis ersetzt. Tipp dann ein Zeichen, und sie gleitet wieder weg.

> Live-Demo (Field): Drück „Continue“ mit einem Titel unter 10 Zeichen: Der Fehler ersetzt den Hinweis, und der Fokus springt ins Feld. Sobald du tippst, gleitet der Fehler weg. Probier sie unter [Field](https://wingo-ui.com/components/field) aus und installier sie mit `npx wingo-ui@latest add field`.

```tsx
// app/contact/contact-form.tsx
"use client"

import { useActionState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
import { sendContact, type ContactState } from "./actions"

const initialState: ContactState = { status: "idle" }

export function ContactForm() {
  const [state, formAction, pending] = useActionState(sendContact, initialState)

  useEffect(() => {
    if (state.status === "sent") {
      toast.success("Nachricht gesendet", { description: "Wir antworten innerhalb eines Werktags." })
    }
    if (state.status === "error" && state.message) toast.error(state.message)
  }, [state])

  return (
    <form action={formAction} noValidate className="relative flex flex-col gap-4">
      <Input
        name="name"
        label="Name"
        autoComplete="name"
        required
        defaultValue={state.values?.name}
        error={state.errors?.name?.[0]}
      />
      <Input
        name="email"
        type="email"
        label="E-Mail"
        autoComplete="email"
        required
        defaultValue={state.values?.email}
        error={state.errors?.email?.[0]}
      />
      <Textarea
        name="message"
        label="Nachricht"
        placeholder="Wobei können wir helfen?"
        required
        minRows={5}
        defaultValue={state.values?.message}
        error={state.errors?.message?.[0]}
      />

      {/* Honeypot: außerhalb des Bildschirms, von Tab übersprungen, für Screenreader verborgen */}
      <div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
        <label htmlFor="website">Website</label>
        <input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
      </div>

      <Button type="submit" loading={pending} loadingText="Wird gesendet">
        Nachricht senden
      </Button>
    </form>
  )
}
```

Häng den Toaster einmal mit `<Toaster />` aus `@/components/ui/toast` ins Root-Layout und render das Formular dann aus einer Server-Seite:

```tsx
// app/contact/page.tsx
import type { Metadata } from "next"
import { ContactForm } from "./contact-form"

export const metadata: Metadata = {
  title: "Kontakt zu Northwind",
  description: "Fragen zu Tarifen oder zu deinem Konto? Schreib uns eine Nachricht, wir antworten innerhalb eines Werktags.",
}

export default function ContactRoute() {
  return (
    <main className="mx-auto w-full max-w-lg px-4 py-16">
      <h1 className="text-3xl font-semibold tracking-tight">Schreib uns</h1>
      <p className="mt-2 text-muted-foreground">Wir antworten innerhalb eines Werktags.</p>
      <div className="mt-8">
        <ContactForm />
      </div>
    </main>
  )
}
```

### Warum leert sich das Formular nach einem Fehler?

Weil React 19 es zurücksetzt. Laut der [Doku zu Formularen in React](https://react.dev/reference/react-dom/components/form) (geprüft am 5. November 2026) werden alle unkontrollierten Felder eines Formulars zurückgesetzt, nachdem die Action erfolgreich war. „Erfolgreich“ heißt hier nur, dass die Action keine Exception geworfen hat, also zählt auch eine Action, die Validierungsfehler zurückgibt. Ohne die Rückgabe von `values` verliert ein Besucher, der sich bei der E-Mail vertippt, eine Nachricht mit 600 Zeichen. Weil die Werte als `defaultValue` zurückkommen, stellt das Zurücksetzen wieder her, was er getippt hat. Nach einem echten Versand ist `values` leer, und das Formular leert sich, wie es soll.

`noValidate` schaltet die Fehlerblasen des Browsers ab, damit jede Meldung aus dem zod-Schema kommt, an derselben Stelle und im selben Stil. Die Props `required` zeichnen trotzdem die Pflichtfeld-Markierung und setzen `aria-required`.

## Gibt es eine Vorlage für ein Next.js-Kontaktformular?

Ja, und sie ist kostenlos. Wer nach „next js contact form template“ sucht und eine ganze Kontaktseite als Vorlage statt eines nackten Formulars will, bekommt mit der [Vorlage Contact Page](https://wingo-ui.com/templates/contact-page) von Wingo UI die komplette Seite: Themenkarten (Sales, Support, Partnerships, Press), Name, E-Mail, Firma, Firmengröße und eine Nachricht mit Live-Zeichenzähler, eine Einwilligungs-Checkbox, kopierbare Kanäle für E-Mail und Telefon, Öffnungszeiten mit Live-Status (offen oder geschlossen), Büros auf einer gezeichneten Karte, ein FAQ und eine Logo-Reihe. Neben der FAQ-Seite ist sie eine der beiden Vorlagen im kostenlosen Angebot.

```bash
npx wingo-ui@latest add contact-page
```

Füll das Formular aus und schick es ab. Die Demo wartet 900 ms und zeigt dann die Bestätigung; ist ein Feld leer, springt der Fokus zum ersten Problem.

> Live-Demo (Contact Page): Wähl ein Thema, lass die E-Mail leer und drück „Send message“: Der Fehler erscheint unter dem Feld, und der Fokus springt hin. Füll alles aus, und das Formular wird zur Bestätigung „Message sent“. Probier sie unter [Contact Page](https://wingo-ui.com/templates/contact-page) aus und installier sie mit `npx wingo-ui@latest add contact-page`.

Die Vorlage validiert im Client und ruft `onSubmit(values)` mit `{ name, email, company, companySize, topic, message, consent }` auf. Solange das Promise läuft, dreht sich der Spinner im Button; wird es abgelehnt, zeigt die Vorlage ihre Meldung „didn't go through“. Auf dem Server brauchst du also eine zweite Action in derselben Datei, die ein Objekt nimmt und bei einem Fehler wirft. Die Vorlage verlangt außerdem mindestens 20 Zeichen in der Nachricht, und das gemeinsame Schema stimmt damit überein:

```ts
// app/contact/actions.ts: diesen Import oben in der Datei ergänzen
import type { ContactFormValues } from "@/components/templates/contact-page"

// und das hier unter sendContact
const pageSchema = contactSchema.extend({
  company: z.string().trim().max(200),
  companySize: z.string().max(40),
  topic: z.enum(["sales", "support", "partnerships", "press"]),
  consent: z.literal(true),
})

export async function sendContactPage(values: ContactFormValues) {
  const ip = (await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"
  if (!rateLimit(`contact:${ip}`, 5, 10 * 60_000)) throw new Error("Rate limited")
  const parsed = pageSchema.parse(values)
  await sendContactMail(parsed)
}
```

Pack die Vorlage in eine kleine Client-Datei. Die Route bleibt eine Server-Seite, die `metadata` exportieren kann, und der Wrapper ist der Ort für Props, die eine Server-Seite nicht übergeben kann, etwa Labels als Funktionen wie `successDescription`:

```tsx
// app/contact/contact.tsx
"use client"

import Link from "next/link"
import { ContactPage } from "@/components/templates/contact-page"
import { sendContactPage } from "./actions"

export function Contact() {
  return (
    <ContactPage
      linkComponent={Link}
      header={false}
      footer={false}
      onSubmit={sendContactPage}
      showOffices={false}
      showLogos={false}
      channels={[
        { kind: "email", title: "Support", description: "Hilfe zu deinem Konto.", value: "support@northwind.com" },
      ]}
      hours={{ timeZone: "America/New_York", days: [1, 2, 3, 4, 5], open: 9, close: 18, label: "Mo bis Fr, 9 bis 18 Uhr ET" }}
      labels={{
        title: "Sprich mit Northwind",
        messagePlaceholder: "Erzähl uns, wobei du Hilfe brauchst.",
        consent: "Ich bin einverstanden, dass Northwind mich zu dieser Anfrage kontaktiert.",
      }}
    />
  )
}
```

Render dann `<Contact />` in `app/contact/page.tsx` statt `<ContactForm />`. Die Vorlage hat kein Honeypot-Feld, hier tragen also das Rate Limit und das Schema auf dem Server den Spamschutz; die Datei gehört dir, falls du eins ergänzen willst. Ersetz vor dem Launch die Beispielinhalte, die noch zu sehen sind: das FAQ (übergib `faq` oder setz `showFaq={false}`) und, falls du sie wieder einschaltest, die Acme-Büros und die Logo-Reihe, deren Titel 12.000 Teams behauptet, die nicht deine sind. Wenn du die Themenkarten änderst, änderst du das `z.enum` in `pageSchema` gleich mit. Auf Smartphones öffnet sich das Select für die Firmengröße als Bottom Sheet und die Kanäle rutschen unter das Formular, die Seite braucht also keine eigene Mobile-Arbeit.

## Was sollte ich prüfen, bevor die Kontaktseite live geht?

- **Zustellbarkeit.** Sende von einer Domain, für die SPF, DKIM und DMARC bei deinem Anbieter eingerichtet sind, und teste, dass eine Antwort beim Besucher ankommt.
- **Secrets.** `SMTP_PASS` steht nur in den Umgebungsvariablen des Servers. Mit `import "server-only"` im Mailer bricht ein Import aus dem Client den Build ab.
- **Missbrauch.** Schick das Formular sechsmal in einer Minute ab und prüf, dass der sechste Versuch abgelehnt wird. Füll den Honeypot über die Dev Tools aus und prüf, dass nichts ankommt.
- **Fehlertoleranz.** Stopp den SMTP-Server, sende eine lange Nachricht und prüf, dass der Text mit einem Fehler-Toast im Formular stehen bleibt.
- **Datenschutz.** Verlink deine Datenschutzerklärung unter dem Formular (`privacyHref` in der Vorlage) und schreib dazu, was du mit der Adresse machst.

Weitere Screens, Schritt für Schritt gebaut, findest du in der [Kategorie Build it](https://wingo-ui.com/de/blog/category/build), angefangen beim [Tutorial zum Next.js Admin Dashboard](https://wingo-ui.com/de/blog/nextjs-admin-dashboard-tutorial). Für längere Formulare gehen der [Guide zu mehrstufigen Formularen in React](https://wingo-ui.com/de/blog/react-multi-step-form) und der [Guide zu Formular-Komponenten in React](https://wingo-ui.com/de/blog/react-form-components-guide) tiefer auf Felder ein, und das [Tutorial zur shadcn Login Page](https://wingo-ui.com/de/blog/shadcn-login-page) nutzt dasselbe Muster mit Server Actions für die Anmeldung.

## Wie geht es weiter?

Fang mit der Vorlage an, wenn du heute eine fertige Seite willst: `npx wingo-ui@latest add contact-page`, `onSubmit` mit der Action von oben verbinden und die Beispielinhalte ersetzen. Contact Page, Field, Input, Textarea, Button und Toast gehören alle zum kostenlosen Angebot. Ein kostenloses Konto (nur E-Mail, keine Karte) reicht also, um jede Datei von Wingo UI aus diesem Tutorial zu installieren.

## Komponenten in diesem Artikel

- [Contact Page](https://wingo-ui.com/templates/contact-page) (Free): A React contact page template: a validated form, copyable channels with live office hours, offices on a drawn map, a short FAQ and logos. Installation: `npx wingo-ui@latest add contact-page`
- [Field](https://wingo-ui.com/components/field) (Free): A React form field wrapper every control shares: label, hint, animated error and counter, fieldsets, and the box recipe all inputs use. Installation: `npx wingo-ui@latest add field`
- [Input](https://wingo-ui.com/components/input) (Free): A React text input: icons and addons inside the box, a clear button, a loading spinner, a character counter and floating labels. Installation: `npx wingo-ui@latest add input`
- [Textarea](https://wingo-ui.com/components/textarea) (Free): An auto-growing React textarea with a character counter, an optional resize handle, a toolbar slot and mod+enter to submit. Installation: `npx wingo-ui@latest add textarea`
- [Toast](https://wingo-ui.com/components/toast) (Free): React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read. Installation: `npx wingo-ui@latest add toast`

## Häufige Fragen

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

Schick das Formular an eine Server Action, validiere die Felder dort mit zod und ruf aus der Action deinen E-Mail-Anbieter auf: einen SMTP-Transport wie Nodemailer oder das SDK eines Anbieters. Die Zugangsdaten liegen in Umgebungsvariablen, die nur der Server sieht, und die Adresse des Besuchers setzt du als replyTo, nie als from.

### Server Action oder API-Route für ein Kontaktformular in Next.js?

Im App Router ist die Server Action der kürzere Weg: Das Formular schickt seine Daten ohne fetch-Aufruf dorthin, es funktioniert schon, bevor JavaScript geladen ist, und Feldfehler gehen direkt an useActionState zurück. Ein Route Handler bleibt die richtige Wahl, wenn auch andere Clients denselben Endpunkt aufrufen, etwa eine Mobile App oder ein Webhook.

### Warum leert mein Next.js-Formular nach einem Validierungsfehler alle Felder?

React 19 setzt unkontrollierte Felder zurück, sobald eine Form Action ohne Exception endet, und das Zurückgeben von Validierungsfehlern zählt als Ende ohne Exception. Gib die eingegebenen Werte aus der Action zurück und reich sie als defaultValue weiter, dann stellt das Zurücksetzen genau das wieder her, was der Besucher getippt hat.

### Wie halte ich Spam ohne Captcha von einem Kontaktformular fern?

Mit einem Honeypot: einem Textfeld, das Menschen nicht sehen und Bots ausfüllen. Jede Anfrage, in der es einen Wert hat, verwirfst du. Kombinier das mit einem Rate Limit pro IP und Validierung auf dem Server; ein Captcha brauchst du erst, wenn trotzdem noch Spam durchkommt.

### Gibt es eine kostenlose Vorlage für eine Kontaktseite in Next.js?

Ja. Die Vorlage Contact Page von Wingo UI gehört zum kostenlosen Angebot: ein validiertes Formular mit Themenkarten, kopierbaren Kanälen, Öffnungszeiten, Büros auf einer Karte und einem FAQ. Installier sie mit npx wingo-ui@latest add contact-page und übergib eine Server Action an onSubmit.

---

Quelle: https://wingo-ui.com/de/blog/nextjs-contact-form
