Kirim formulir ke email Anda5 menit baca
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.
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:
- Mengirim sendiri: action memvalidasi field, memeriksa honeypot, dan memanggil penyedia email Anda dengan API key. Pesannya hanya ada sebagai email itu.
- 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.
| 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.
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: {} };
}- Tipe state ada di file tersendiri karena file
"use server"hanya boleh mengekspor fungsi async. useFormStatusmembaca 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.
replyTodi versi Resend, dan field bernamaemaildi 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
sendMaildenganfrom,to,replyTo,subject, dantext. - Untuk versi Ikiform, definisikan kelima field di tab Fields formulir dengan
budgetsebagaiselect, agar nilai yang salah mendapat422 validation_faileddengan pesan per field.
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 menunjukkan komponen client-nya.
Pastikan berfungsi
- Kirim dengan anggaran kosong. Error tampil di bawah select dan tidak ada isian lain yang hilang.
- Kirim permintaan sungguhan. Kalimat sukses menggantikan formulir.
- Buka email dan tekan Reply. Baris To berisi alamat yang Anda masukkan.
- Isi honeypot tersembunyi lewat dev tools browser lalu kirim. Anda melihat sukses, dan tidak ada email yang datang.
- 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 kehtml, escape setiap nilai terlebih dulu, atau pengunjung bisa menyisipkan markup dan link ke inbox Anda. - Header injection. Jangan pernah menaruh nilai formulir mentah di
fromatauto.replyToaman dengan Resend karena Zod sudah memeriksa bahwa isinya email. - Environment variable.
RESEND_API_KEYdanIKIFORM_SECRET_KEYtidak boleh diawaliNEXT_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
idempotencyKeydari Resend, atau headerIdempotency-Keydari 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
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.
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.
Ya, API key tetap di server selama namanya tidak diawali NEXT_PUBLIC_. Validasi setiap field di dalam action, karena Server Action bisa dipanggil langsung.
Tidak untuk mengirim email. Anda butuh database jika ingin catatan pesan yang tetap ada saat email hilang. Form backend menyimpan catatan itu untuk Anda.
Tidak. Ikiform hanya mengirim email ke Anda dan tim. Kirim konfirmasi dari penyedia email Anda sendiri, di Server Action atau dari webhook.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.