Kirim formulir ke email Anda

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.

Preet SutharPendiri, Ikiform

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:

Apa yang dilakukan Server Action saat mengirim email sendiri, dan saat meneruskan kirimankirim sendiriteruskanformulirAndauseActionStateactionAndavalidasi,cekhoneypotResendatauSMTPdomainterverifikasiAndahanyainboxtanpasalinanjika hilangformulirAndauseActionStateactionAndatambahsecret keyIkiformvalidasi,skor, simpaninbox dandashboardpluswebhookFormDataAPI keyFormDataJSONApa yang dilakukan Server Action saat mengirim email sendiri, dan saat meneruskan kirimankirim sendiriteruskanformulir AndauseActionStateaction Andavalidasi, cek honeypotResend atau SMTPdomain terverifikasi Andahanya inboxtanpa salinan jika hilangformulir AndauseActionStateaction Andatambah secret keyIkiformvalidasi, skor, simpaninbox dan dashboardplus webhookFormDataAPI keyFormDataJSON
  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.
TugasServer Action dengan Resend atau NodemailerServer Action dengan Ikiform
ValidasiZod di actionDefinisi field formulir, diterapkan di sisi Ikiform
SpamHoneypot Anda; selebihnya Anda tambahkan sendiriHoneypot, skor spam, dan pemeriksaan manusia opsional
Rate limitTidak ada sampai Anda menambahkan store seperti RedisBawaan, per pengunjung dan per formulir
Domain pengirimVerifikasi domain Anda dengan record SPF dan DKIMTidak perlu: email datang dari notifications@ikiform.com
Subjek dan desainApa pun yang Anda mauTetap: New submission: <form name>
Konfirmasi ke pengunjungSatu panggilan send lagiTidak tersedia; kirim dari alat email Anda
Salinan setiap pesanHanya jika Anda menambahkan databaseDashboard, 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
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: {} };
}
  • 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 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 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

MasalahYang harus dilakukan
Resend menjawab bahwa domain belum diverifikasiTambahkan record DNS dari Resend untuk domain di from, atau kirim dari alamat uji Resend selama pengembangan.
429 rate_limitedSatu 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_failedKiriman 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 productionKey-nya tidak ada di environment variable deployment. Tambahkan ke environment production lalu deploy ulang.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.