Form kontak yang tahan spam dengan Next.js Server Actions
Cara kerja form kontak di situs ini: Server Action, validasi Zod, useActionState, honeypot, pengecekan waktu, dan Resend.
3 menit baca
Di halaman ini
Kebutuhan
Form kontak di situs ini punya daftar kebutuhan yang singkat:
- Tanpa API route terpisah dan tanpa kode fetch di client.
- Aturan validasi yang sama di server, dengan pesan yang ramah untuk setiap field.
- Isian tetap ada ketika terjadi kegagalan, supaya tidak ada yang perlu mengetik ulang pesan panjang.
- Perlindungan spam dasar tanpa CAPTCHA.
- Pengiriman email lewat Resend, dengan API key yang hanya ada di server.
Next.js Server Actions yang dipadukan dengan useActionState dari React 19 memenuhi semuanya.
Satu skema untuk semua aturan
// lib/validations.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().max(200).pipe(z.email()),
message: z.string().trim().min(10).max(5000),
});
export type ContactInput = z.infer<typeof contactSchema>;
export type ContactField = keyof ContactInput;Server Action-nya
Action menerima state sebelumnya dan FormData yang dikirim, lalu mengembalikan state berikutnya.
State-nya berupa discriminated union kecil, sehingga form selalu tahu persis apa yang terjadi:
"use server";
export type ContactState =
| { status: "idle" }
| { status: "success" }
| {
status: "error";
error: "validation" | "rateLimited" | "server";
fields: ContactField[];
values: Partial<ContactInput>;
};
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: readString(formData, "name"),
email: readString(formData, "email"),
message: readString(formData, "message"),
};
const parsed = contactSchema.safeParse(values);
if (!parsed.success) {
const fields = [...new Set(parsed.error.issues.map((issue) => issue.path[0] as ContactField))];
return { status: "error", error: "validation", fields, values };
}
const { name, email, message } = parsed.data;
const { error } = await new Resend(process.env.RESEND_API_KEY).emails.send({
from: process.env.CONTACT_FROM_EMAIL!,
to: process.env.CONTACT_TO_EMAIL!,
replyTo: email,
subject: `Portfolio contact from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
});
return error ? { status: "error", error: "server", fields: [], values } : { status: "success" };
}Mengembalikan values saat error itulah yang menjaga isian pengguna: form memakainya sebagai nilai
default di render berikutnya. Mengisi replyTo dengan alamat pengunjung berarti membalas dari inbox
langsung tertuju ke mereka.
Form-nya
"use client";
import { useActionState } from "react";
const initialState: ContactState = { status: "idle" };
export function ContactForm() {
const [state, formAction, pending] = useActionState(sendContact, initialState);
if (state.status === "success") return <p role="status">Thanks! I will get back to you soon.</p>;
const error = state.status === "error" ? state : null;
const invalid = (field: ContactField) => error?.fields.includes(field) ?? false;
return (
<form action={formAction} noValidate>
<label htmlFor="contact-email">Email</label>
<input
id="contact-email"
name="email"
type="email"
defaultValue={error?.values.email}
aria-invalid={invalid("email") || undefined}
aria-describedby={invalid("email") ? "contact-email-error" : undefined}
/>
{invalid("email") ? <p id="contact-email-error">Enter a valid email address.</p> : null}
{/* name and message follow the same pattern */}
<button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
</form>
);
}pending langsung didapat dari useActionState, jadi tidak perlu state loading manual. Karena form
memakai action yang sungguhan, form tetap bisa dikirim walaupun JavaScript belum termuat.
Perlindungan spam tanpa CAPTCHA
Sebagian besar spam form kontak datang dari bot sederhana. Dua pengecekan murah menghentikan sebagian besar di antaranya:
- Field honeypot. Sebuah input bernama
website, disembunyikan dari manusia dengan CSS danaria-hidden, serta dilewati keyboard dengantabIndex={-1}. Manusia membiarkannya kosong; bot naif mengisi semua field. - Pengecekan waktu. Form mencatat kapan ia ditampilkan di field tersembunyi
startedAt. Kiriman yang datang kurang dari tiga detik bukan diketik oleh manusia.
const startedAt = Number(readString(formData, "startedAt"));
const tooFast = startedAt > 0 && Date.now() - startedAt < 3_000;
if (readString(formData, "website") || tooFast) {
// Pretend it worked, so the bot has no signal to adapt to.
return { status: "success" };
}startedAt diisi lewat effect di client. Kalau JavaScript belum berjalan, field itu kosong, dan hal itu
saja tidak dianggap sebagai tanda bot.
Rate limiting
Di atas itu, sliding window kecil di memori mengizinkan lima pesan per IP per jam. Di platform serverless, memori ini terpisah per instance, jadi hanya meredam lonjakan; untuk batas yang lebih ketat, langkah berikutnya adalah storage bersama seperti Redis. Untuk situs pribadi, honeypot dan pengecekan waktu sudah memikul sebagian besar beban.
Ringkasan
- Server Action ditambah
useActionStatemenggantikan API route dan kode fetch di client. - Satu skema Zod mendefinisikan aturan; action mengembalikan error per field dan nilai aslinya.
- Honeypot dan pengecekan waktu menghentikan bot sederhana tanpa CAPTCHA.
- Rahasia tetap di server, dan form berfungsi sebelum JavaScript termuat.