Hentikan spam formulir kontak8 menit baca
Perlindungan spam formulir Next.js dengan Turnstile
Lindungi formulir Next.js dari spam: honeypot, Turnstile yang dicek di server action, limit per IP di Vercel, Netlify, atau Cloudflare, dan CSP-nya.
Di Next.js, periksa spam di dalam server action, karena action adalah endpoint POST publik yang bisa dipanggil siapa pun tanpa formulir Anda. Batasi berdasarkan IP client yang diatur hosting Anda, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya dengan honeypot utuh. Muat skrip Turnstile lewat next/script dengan nonce CSP Anda, dan izinkan challenges.cloudflare.com di frame-src.
Apa yang berbeda di Next.js
Server action terlihat seperti pemanggilan fungsi, tetapi Next.js mengubahnya menjadi endpoint POST dengan ID di JavaScript halaman. Bot tidak butuh formulir Anda. Bot bisa mengulang POST itu dengan field apa saja, secepat apa pun. Jadi setiap pemeriksaan harus ada di dalam action, jangan hanya di komponen.
Tiga detail Next.js membentuk sisa halaman ini:
- Tidak ada
request.ip. IP client datang dari header, dan header mana yang bisa dipercaya bergantung pada hosting. - Nonce berarti rendering dinamis. Nonce per request tidak bisa diterapkan pada halaman yang dibangun saat build.
- Navigasi client membuat halaman tetap hidup. Skrip Turnstile yang dimuat sekali tidak akan merender widget lagi setelah soft navigation.
- Formulir, atau skrip yang mengulangnya, mengirim ke server action.
- Action memeriksa hitungan pengunjung terhadap aturan Vercel Firewall. Jika melewati batas, action mengembalikan state error dan berhenti.
- Action mengirim token Turnstile dan IP client ke siteverify, lalu memeriksa nama action dan hostname di jawabannya.
- Baru setelah itu action meneruskan field, termasuk honeypot, ke Ikiform, yang memberi skor dan menyimpan pesannya.
Setup proyek
File yang ditambahkan halaman ini ke proyek App Router:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localBuat widget Turnstile di dashboard Cloudflare dan daftarkan setiap hostname tempat formulir berjalan. Selama pengembangan, pakai test key Cloudflare, yang lolos di host mana pun, termasuk localhost:
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip- Hanya
NEXT_PUBLIC_TURNSTILE_SITE_KEYyang sampai ke browser. Next.js menyisipkan nilaiNEXT_PUBLIC_saat build, jadi mengganti site key butuh build baru. TURNSTILE_HOSTNAMEdibiarkan kosong secara lokal, karena token uji tidak membawa hostname Anda. Atur ke domain asli Anda di production.- Ganti secret dengan
2x0000000000000000000000000000000AAuntuk melihat setiap pemeriksaan gagal.
Baca IP client yang diatur hosting Anda
Rate limit yang memakai header yang bisa ditulis pengunjung sebagai kunci bukanlah limit. Setiap hosting mengatur header yang ditimpanya saat request masuk, dan hanya header itu yang bisa dipercaya:
- clientIp() membaca satu header yang disebut di CLIENT_IP_HEADER dan mengabaikan sisanya.
- Vercel menimpa x-forwarded-for dan x-real-ip dengan alamat pengunjung.
- Netlify menaruh alamatnya di x-nf-client-connection-ip.
- Cloudflare, termasuk Next.js yang di-deploy dengan OpenNext, mengatur cf-connecting-ip.
import "server-only";
import { headers } from "next/headers";
const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";
export async function clientIp() {
const value = (await headers()).get(HEADER);
return value?.split(",")[0]?.trim() || null;
}Verifikasi Turnstile di helper khusus server
import "server-only";
interface Verdict {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
}
export type Check = { ok: true } | { ok: false; reason: string };
export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };
let verdict: Verdict;
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
signal: AbortSignal.timeout(5000),
});
verdict = (await response.json()) as Verdict;
} catch {
return { ok: false, reason: "siteverify-unreachable" };
}
if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
const expected = process.env.TURNSTILE_HOSTNAME;
if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
return { ok: true };
}- Token berlaku selama 300 detik dan hanya bisa diverifikasi sekali, setelah itu siteverify mengembalikan
timeout-or-duplicate. successsaja tidak cukup. Token yang dibuat di situs lain dengan site key Anda juga berbunyisuccess, jadi helper memeriksaactiondan, di production,hostname.- Helper ini gagal secara tertutup. Jika Cloudflare tidak bisa dihubungi, pengunjung melihat error dan tidak ada bot yang lolos.
Server action
"use server";
import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";
export interface ContactState {
status: "idle" | "sent" | "error";
message: string;
fields?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const fields = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
if (process.env.VERCEL) {
const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
}
const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
if (!check.ok) {
console.warn("turnstile", check.reason);
return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
}
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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}Di Vercel, limit datang dari Firewall. Buka Firewall proyek Anda, tambahkan aturan dengan kondisi @vercel/firewall, atur Rate limit ID ke contact-form, lalu pilih jendela waktu seperti 5 request per menit. checkRateLimit menerima header request, jadi bisa dipakai di dalam server action yang tidak punya objek Request, dan mengelompokkan berdasarkan IP client kecuali Anda memberikan rateLimitKey.
- Vercel menghitung setiap aturan per region, jadi traffic yang tersebar di beberapa region bisa melebihi angka yang Anda atur. Untuk formulir kontak itu tidak masalah; untuk hitungan global yang tepat, pakai Redis, seperti di halaman rate limiting.
- Header
Originpengunjung diteruskan karena fetch dari sisi server tidak mengirimnya, dan Ikiform membacanya untuk mengenali uji gratis darilocalhost. - Di luar Vercel, panggilan Firewall dilewati. Taruh limiter Redis atau Workers di sana, dengan
clientIp()sebagai kuncinya.
IKIFORM_SECRET_KEY di environment variable khusus server.Formulir dan widget
"use client";
import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";
interface Turnstile {
render(container: HTMLElement, options: { sitekey: string; action: string }): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: Turnstile;
}
}
const initial: ContactState = { status: "idle", message: "" };
export function ContactForm({ nonce }: { nonce?: string }) {
const [state, action, pending] = useActionState(sendMessage, initial);
const box = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
function mount() {
if (!box.current || !window.turnstile || widget.current) return;
widget.current = window.turnstile.render(box.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
action: "contact",
});
}
useEffect(() => {
if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
}, [state]);
useEffect(
() => () => {
if (widget.current) window.turnstile?.remove(widget.current);
widget.current = null;
},
[],
);
if (state.status === "sent") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
<div className="hp" aria-hidden="true">
<label htmlFor="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div ref={box} />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
</form>
);
}?render=explicitmencegah skrip memindai halaman untuk mencari widget.onReadyberjalan begitu skrip dimuat dan setiap kali komponen di-mount, yang mencakup navigasi sisi client.- Token hanya berlaku sekali. Saat action mengembalikan error, effect mereset widget agar percobaan berikutnya membawa token baru.
- Formulir juga bisa mengirim sebelum hydration, karena
actionadalah server action itu sendiri. Tanpa JavaScript tidak ada widget, jadi siteverify menolak kiriman itu; pemeriksaan manusia butuh browser. - Class
.hpdari panduan honeypot menyembunyikan honeypot di luar layar.
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";
export default async function Page() {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<main>
<h1>Contact us</h1>
<ContactForm nonce={nonce} />
</main>
);
}CSP yang membiarkan Turnstile berjalan
Next.js membaca nonce dari header request Content-Security-Policy dan menambahkannya ke skripnya sendiri serta ke setiap <Script> yang diberi prop nonce. Cloudflare menyatakan Turnstile meneruskan nonce ke apa yang dimuatnya, dan iframe-nya butuh frame-src https://challenges.cloudflare.com. Di Next.js 16 file-nya adalah proxy.ts, yang menggantikan middleware.ts:
import { type NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const dev = process.env.NODE_ENV === "development";
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
"frame-src https://challenges.cloudflare.com",
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' blob: data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", policy);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", policy);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};- Dengan
'strict-dynamic', browser modern memercayai nonce dan mengabaikan host, yang tetap dipertahankan untuk browser lama. - Membaca
headers()di halaman membuatnya dirender per request, yang memang dibutuhkan nonce. Halaman kontak yang dibangun statis tidak bisa membawa nonce; pakai policy tanpa nonce diheaders()miliknext.configsebagai gantinya, dengan konsekuensi'unsafe-inline'.
Deploy
| Hosting | CLIENT_IP_HEADER | Rate limit | Catatan |
|---|---|---|---|
| Vercel | x-real-ip | Aturan Firewall dan checkRateLimit | Tambahkan aturan untuk setiap environment yang Anda uji |
| Netlify | x-nf-client-connection-ip | Redis, seperti Upstash | Server action berjalan dalam satu Netlify function |
| Cloudflare, dengan OpenNext | cf-connecting-ip | Binding rate limiting Workers | Binding-nya bersifat perkiraan dan dihitung per lokasi |
Atur TURNSTILE_HOSTNAME ke domain asli Anda di production. Deployment preview punya hostname sendiri, jadi tambahkan ke widget atau matikan pemeriksaan di preview.
Biarkan Ikiform menjalankan pemeriksaannya
Semua yang di atas ada karena server action meneruskan dengan secret key, yang mematikan pemeriksaan manusia dan limit per pengunjung milik Ikiform. Kirim dari browser saja, dan Ikiform menjalankan honeypot, Turnstile, dan rate limit untuk Anda. Tanpa key Cloudflare, tanpa perubahan proxy.ts selain mengizinkan https://ikiform.com di frame-src dan connect-src, dan tanpa action:
"use client";
import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const frame = useRef<HTMLDivElement>(null);
const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
try {
const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
const result = await submit(FORM_ID, data, { turnstileToken });
return result.ok ? "sent" : result.error.message;
} catch {
return "The human check didn't finish. Try again.";
}
}, "");
if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required minLength={10} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<div ref={frame} />
{message ? <p role="alert">{message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Atur Spam check formulir ke Ikiform dan Rate limit-nya ke Strict, 2 per 10 menit per pengunjung, lalu tambahkan domain Anda ditambah localhost di Allowed domains. challenge() memasang frame kecil ikiform.com yang tetap tak terlihat kecuali Turnstile butuh klik. Spam mendapat jawaban sukses yang sama dengan pesan asli, disimpan beserta alasan tertangkapnya, dan disimpan selama 30 hari sebelum dihapus. Panduan setup Next.js membahas cara membuat formulir dan key-nya.
Pastikan berfungsi
- Jalankan
next devdengan test key dan kirim pesan. Pesan masuk di Submissions. Tanpa paket, workspace menerima 50 kiriman uji per bulan darilocalhost. - Atur
TURNSTILE_SECRET_KEYke secret yang selalu gagal lalu kirim. Formulir menampilkan error pemeriksaan manusia dan mempertahankan teks Anda. - Di preview Vercel dengan aturan Firewall, kirim enam kali dalam satu menit. Kiriman keenam menampilkan pesan limit.
- Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Buka halaman dengan console terbuka. Tidak ada pelanggaran CSP untuk
challenges.cloudflare.com.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
| Widget tidak pernah muncul setelah navigasi ke halaman | Skrip dimuat di halaman sebelumnya dan tidak akan memindai lagi. Render dari onReady, seperti di contoh, bukan dari onLoad. |
| Setiap pesan gagal pemeriksaan manusia di production | TURNSTILE_HOSTNAME tidak cocok dengan hostname yang dikembalikan siteverify, sering kali www. versus domain apex. Catat check.reason di log untuk melihat yang mana. |
| Semua pengunjung berbagi satu bucket rate limit | CLIENT_IP_HEADER menyebut header yang tidak diatur hosting Anda, sehingga clientIp() mengembalikan null. Pilih header dari tabel di atas. |
checkRateLimit mengembalikan error: "not-found" | Tidak ada aturan Firewall dengan Rate limit ID contact-form di environment ini. Tambahkan, lalu publikasikan perubahan Firewall. |
Pertanyaan
Render widget di client component, muat skripnya dengan next/script, dan baca field cf-turnstile-response di server action. Kirim bersama secret key Anda ke siteverify dan lanjutkan hanya jika jawabannya menyatakan success dengan action dan hostname yang Anda harapkan.
Baca dari header yang diatur hosting Anda dengan headers() dari next/headers: x-real-ip atau x-forwarded-for di Vercel, x-nf-client-connection-ip di Netlify, cf-connecting-ip di Cloudflare. Next.js tidak punya request.ip di App Router.
Ya. Nonce dibuat per request, jadi halaman harus dirender di setiap request. Biarkan halaman kontak dinamis, atau pakai policy tanpa nonce yang mengizinkan challenges.cloudflare.com berdasarkan host.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.