# Formulir kontak Next.js yang mengirim email

> Dua cara mengirim email dari formulir Next.js dengan Server Action: kirim sendiri dengan Resend atau Nodemailer, atau teruskan ke form backend, dibandingkan secara jujur.

Terbit 2026-10-25 · https://ikiform.com/id/guides/send-form-to-email/nextjs

Di App Router, Server Action menerima `FormData` formulir di server, tempat API key email aman. Anda bisa mengirim email sendiri dengan Resend atau Nodemailer, atau meneruskan kiriman ke form backend yang menyaring spam, menyimpannya, dan mengirimi Anda email. Mengirim sendiri memberi Anda kendali penuh atas email; meneruskan menghemat Anda dari membangun penyaringan spam, rate limit, dan penyimpanan.

## Apa yang berbeda di sini

Next.js sudah punya server, jadi alasan umum memakai form backend, "situs saya tidak bisa menjalankan kode", tidak berlaku. Pertanyaannya adalah apa yang harus dikerjakan kode itu. Mengirim email adalah bagian yang mudah. Semua hal di sekitarnya yang memakan waktu:

Diagram: Apa yang dilakukan Server Action saat mengirim email sendiri, dan saat meneruskan kiriman

1. Mengirim sendiri: action memvalidasi field, memeriksa honeypot, dan memanggil penyedia email Anda dengan API key. Pesannya hanya ada sebagai email itu.
2. Meneruskan: action mengirim field ke Ikiform dengan secret key Anda. Ikiform memvalidasi terhadap definisi formulir, memberi skor spam, menyimpan kiriman, dan mengirim email ke para penerima.

Keduanya menyimpan setiap key di server. Bedanya adalah seberapa banyak kotak tengah yang Anda tulis dan pelihara.

| Tugas | Server Action dengan Resend atau Nodemailer | Server Action dengan Ikiform |
| --- | --- | --- |
| Validasi | Zod di action | Definisi field formulir, diterapkan di sisi Ikiform |
| Spam | Honeypot Anda; selebihnya Anda tambahkan sendiri | Honeypot, skor spam, dan pemeriksaan manusia opsional |
| Rate limit | Tidak ada sampai Anda menambahkan store seperti Redis | Bawaan, per pengunjung dan per formulir |
| Domain pengirim | Verifikasi domain Anda dengan record SPF dan DKIM | Tidak perlu: email datang dari `notifications@ikiform.com` |
| Subjek dan desain | Apa pun yang Anda mau | Tetap: `New submission: <form name>` |
| Konfirmasi ke pengunjung | Satu panggilan `send` lagi | Tidak tersedia; kirim dari alat email Anda |
| Salinan setiap pesan | Hanya jika Anda menambahkan database | Dashboard, ekspor CSV, dan API |

Pilih versi DIY untuk email bermerek atau konfirmasi ke pengunjung, jika Anda tidak keberatan mengurus spam dan penyimpanan. Pilih meneruskan agar tidak perlu memikirkan formulir lagi setelah berfungsi.

## Kedua versi

Formulirnya berupa permintaan proyek: nama, email kantor, perusahaan, kisaran anggaran, dan deskripsi. Tipe state dan komponen client dipakai bersama; hanya `actions.ts` yang berubah.

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

- Tipe state ada di file tersendiri karena file `"use server"` hanya boleh mengekspor fungsi async.
- `useFormStatus` membaca status pending dari formulir tempatnya berada, jadi tombolnya ada di komponen tersendiri.
- Di versi Resend, honeypot yang terisi mengembalikan sukses tanpa mengirim, jadi bot tidak belajar apa-apa. Ikiform melakukan hal yang sama saat menyimpan kiriman sebagai spam.
- `replyTo` di versi Resend, dan field bernama `email` di versi Ikiform, sama-sama membuat **Reply** membalas ke pengunjung.
- Nodemailer bekerja dengan cara yang sama seperti Resend: buat transport dengan detail SMTP Anda dan panggil `sendMail` dengan `from`, `to`, `replyTo`, `subject`, dan `text`.
- Untuk versi Ikiform, definisikan kelima field di tab **Fields** formulir dengan `budget` sebagai `select`, agar nilai yang salah mendapat `422 validation_failed` dengan pesan per field.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server. Setiap contoh server meneruskan header `Origin` dari browser jika ada, karena `fetch` milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu.

Ada opsi ketiga: lewati action dan biarkan browser mengirim langsung ke Ikiform. Tidak ada secret key yang terlibat, sehingga rate limit Standard dan **Allowed domains** berlaku per pengunjung. [Panduan Next.js di dokumentasi](https://ikiform.com/id/docs/guides/nextjs.md) menunjukkan komponen client-nya.

## Pastikan berfungsi

1. Kirim dengan anggaran kosong. Error tampil di bawah select dan tidak ada isian lain yang hilang.
2. Kirim permintaan sungguhan. Kalimat sukses menggantikan formulir.
3. Buka email dan tekan **Reply**. Baris **To** berisi alamat yang Anda masukkan.
4. Isi honeypot tersembunyi lewat dev tools browser lalu kirim. Anda melihat sukses, dan tidak ada email yang datang.
5. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim. Langkah ini hanya berlaku untuk versi Ikiform.

## Hal yang sering menjebak

- **Escaping di email HTML.** Versi Resend mengirim `text`. Jika Anda beralih ke `html`, escape setiap nilai terlebih dulu, atau pengunjung bisa menyisipkan markup dan link ke inbox Anda.
- **Header injection.** Jangan pernah menaruh nilai formulir mentah di `from` atau `to`. `replyTo` aman dengan Resend karena Zod sudah memeriksa bahwa isinya email.
- **Environment variable.** `RESEND_API_KEY` dan `IKIFORM_SECRET_KEY` tidak boleh diawali `NEXT_PUBLIC_`, atau Next.js akan mengirimnya ke browser.
- **Pengiriman ganda.** Klik ganda bisa menjalankan action dua kali. Status pending menonaktifkan tombol, tetapi jaringan yang lambat tetap bisa memicu race; opsi `idempotencyKey` dari Resend, atau header `Idempotency-Key` dari Ikiform, membuat pengulangan tidak berefek.
- **Server Action adalah endpoint publik.** Siapa pun bisa memanggilnya langsung, karena itulah validasi dan pemeriksaan spam harus berjalan di dalamnya, bukan hanya di formulir.

## Jika ada yang salah

| Masalah | Yang harus dilakukan |
| --- | --- |
| Resend menjawab bahwa domain belum diverifikasi | Tambahkan record DNS dari Resend untuk domain di `from`, atau kirim dari alamat uji Resend selama pengembangan. |
| `429 rate_limited` | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai `Retry-After`, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |
| Action berfungsi di lokal tetapi tidak di production | Key-nya tidak ada di environment variable deployment. Tambahkan ke environment production lalu deploy ulang. |

## Pertanyaan

### Apakah Nodemailer berfungsi di Server Action?

Ya, di runtime Node.js, yang dipakai Server Action secara default. Nodemailer membuka koneksi SMTP, jadi tidak berjalan di runtime Edge, dan beberapa hosting membatasi SMTP keluar. API email berbasis HTTP seperti Resend menghindari keduanya.

### Bagaimana cara mengirim email dari formulir Next.js?

Tangani formulir dengan Server Action, yang berjalan di server. Panggil API email seperti Resend atau Nodemailer dari sana, atau teruskan field ke form backend yang mengirimi Anda email.

### Apakah aman memanggil Resend dari Server Action?

Ya, API key tetap di server selama namanya tidak diawali NEXT_PUBLIC_. Validasi setiap field di dalam action, karena Server Action bisa dipanggil langsung.

### Apakah saya butuh database untuk formulir kontak Next.js?

Tidak untuk mengirim email. Anda butuh database jika ingin catatan pesan yang tetap ada saat email hilang. Form backend menyimpan catatan itu untuk Anda.

### Bisakah Ikiform mengirim email konfirmasi ke pengunjung?

Tidak. Ikiform hanya mengirim email ke Anda dan tim. Kirim konfirmasi dari penyedia email Anda sendiri, di Server Action atau dari webhook.

## Panduan lainnya

- [Kirim formulir ke email Anda](https://ikiform.com/id/guides/send-form-to-email.md): Terima setiap kiriman formulir di inbox tanpa mail server: mengapa mailto: dan mail() PHP gagal, dan setup yang menyaring spam serta memungkinkan Anda membalas.
- [Alternatif EmailJS](https://ikiform.com/id/guides/send-form-to-email/emailjs-alternative.md): Pindahkan formulir kontak dari EmailJS: mengapa key email di sisi browser berisiko, apa yang berubah di kode Anda, dan apa yang Anda lepaskan, seperti balasan otomatis.
- [Beberapa penerima](https://ikiform.com/id/guides/send-form-to-email/multiple-recipients.md): Kirim email dari satu formulir ke seluruh tim, atau arahkan setiap pesan ke orang yang tepat berdasarkan topik, tanpa membiarkan formulir sendiri memilih siapa yang menerima email.
- [Balas dari inbox Anda](https://ikiform.com/id/guides/send-form-to-email/reply-to.md): Buat Reply di notifikasi formulir membalas ke orang yang mengisi formulir: nama field yang berfungsi, kapan memakai _replyto, dan mengapa balasan bisa nyasar.
- [Tanpa PHP](https://ikiform.com/id/guides/send-form-to-email/without-php.md): Ganti skrip contact.php yang memanggil mail() dengan formulir yang mengirim ke endpoint hosted, dengan nama field, halaman terima kasih, dan balasan tetap sama.
- [Ke Gmail](https://ikiform.com/id/guides/send-form-to-email/gmail.md): Terima kiriman formulir di Gmail tanpa link mailto:, app password, atau Apps Script, lalu beri label, jauhkan dari spam, dan balas dari Gmail.
