Langsung ke konten
~/rifqi_abrory

Halaman

Beranda
Tentang
Stack
Pengalaman
Proyek
Tulisan
Kontak
Semua proyek

Proyek

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Artikel

Angular signals untuk halaman admin sehari-hari
Typed reactive forms di Angular, tanpa any
Error API yang konsisten di ASP.NET Core dengan ProblemDetails
Meneruskan CancellationToken sampai ke lapisan terbawah di ASP.NET Core
Graceful shutdown untuk service HTTP di Go
Idempotency key: membuat API pembayaran aman untuk di-retry
Form kontak yang tahan spam dengan Next.js Server Actions
Form yang ramah offline di React Native: draft dan antrean submit
Validasi request dan penanganan error di Spring Boot 3
Keyset pagination di SQL Server: ketika OFFSET mulai lambat

Aksi

Ganti ke mode gelap
Read in English
Salin alamat email
Semua artikel

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:

  1. Field honeypot. Sebuah input bernama website, disembunyikan dari manusia dengan CSS dan aria-hidden, serta dilewati keyboard dengan tabIndex={-1}. Manusia membiarkannya kosong; bot naif mengisi semua field.
  2. 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 useActionState menggantikan 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.