# 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.

Terbit 2026-10-25 · https://ikiform.com/id/guides/spam-protection/nextjs

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.

Diagram: Urutan pemeriksaan di dalam server action Next.js

1. Formulir, atau skrip yang mengulangnya, mengirim ke server action.
2. Action memeriksa hitungan pengunjung terhadap aturan Vercel Firewall. Jika melewati batas, action mengembalikan state error dan berhenti.
3. Action mengirim token Turnstile dan IP client ke siteverify, lalu memeriksa nama action dan hostname di jawabannya.
4. Baru setelah itu action meneruskan field, termasuk honeypot, ke Ikiform, yang memberi skor dan menyimpan pesannya.

Pemeriksaan paling murah berjalan lebih dulu. Honeypot diteruskan tanpa diubah, sehingga honeypot yang terisi disimpan sebagai spam alih-alih dibuang diam-diam.

## Setup proyek

File yang ditambahkan halaman ini ke proyek App Router:

`File`:

```text
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local
```

Buat 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`:

`.env.local`:

```bash
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_KEY` yang sampai ke browser. Next.js menyisipkan nilai `NEXT_PUBLIC_` saat build, jadi mengganti site key butuh build baru.
- `TURNSTILE_HOSTNAME` dibiarkan kosong secara lokal, karena token uji tidak membawa hostname Anda. Atur ke domain asli Anda di production.
- Ganti secret dengan `2x0000000000000000000000000000000AA` untuk 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:

Diagram: Header mana yang membawa IP client asli di setiap hosting

1. clientIp() membaca satu header yang disebut di CLIENT_IP_HEADER dan mengabaikan sisanya.
2. Vercel menimpa x-forwarded-for dan x-real-ip dengan alamat pengunjung.
3. Netlify menaruh alamatnya di x-nf-client-connection-ip.
4. Cloudflare, termasuk Next.js yang di-deploy dengan OpenNext, mengatur cf-connecting-ip.

Vercel mendokumentasikan bahwa ia menimpa X-Forwarded-For untuk mencegah spoofing. Dokumentasi Netlify hanya menyebut context.ip di Functions; x-nf-client-connection-ip adalah header yang ditunjuk staf Netlify.

`lib/client-ip.ts`:

```ts
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

`lib/turnstile.ts`:

```ts
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`.
- `success` saja tidak cukup. Token yang dibuat di situs lain dengan site key Anda juga berbunyi `success`, jadi helper memeriksa `action` dan, di production, `hostname`.
- Helper ini gagal secara tertutup. Jika Cloudflare tidak bisa dihubungi, pengunjung melihat error dan tidak ada bot yang lolos.

## Server action

`app/contact/actions.ts`:

```ts
"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](https://ikiform.com/id/guides/spam-protection/rate-limiting.md).
- Header `Origin` pengunjung diteruskan karena fetch dari sisi server tidak mengirimnya, dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.
- Di luar Vercel, panggilan Firewall dilewati. Taruh limiter Redis atau Workers di sana, dengan `clientIp()` sebagai kuncinya.

> 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](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

## Formulir dan widget

`app/contact/contact-form.tsx`:

```tsx
"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=explicit` mencegah skrip memindai halaman untuk mencari widget. `onReady` berjalan 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 `action` adalah server action itu sendiri. Tanpa JavaScript tidak ada widget, jadi siteverify menolak kiriman itu; pemeriksaan manusia butuh browser.
- Class `.hp` dari [panduan honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md) menyembunyikan honeypot di luar layar.

`app/contact/page.tsx`:

```tsx
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`:

`proxy.ts`:

```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 di `headers()` milik `next.config` sebagai 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:

`app/contact/contact-form.tsx`:

```tsx
"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](https://ikiform.com/id/docs/guides/nextjs.md) membahas cara membuat formulir dan key-nya.

## Pastikan berfungsi

1. Jalankan `next dev` dengan test key dan kirim pesan. Pesan masuk di **Submissions**. Tanpa paket, workspace menerima 50 kiriman uji per bulan dari `localhost`.
2. Atur `TURNSTILE_SECRET_KEY` ke secret yang selalu gagal lalu kirim. Formulir menampilkan error pemeriksaan manusia dan mempertahankan teks Anda.
3. Di preview Vercel dengan aturan Firewall, kirim enam kali dalam satu menit. Kiriman keenam menampilkan pesan limit.
4. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
5. 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

### Bagaimana menambahkan Cloudflare Turnstile ke server action Next.js?

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.

### Bagaimana mendapatkan IP client di server action Next.js?

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.

### Apakah nonce CSP membuat halaman kontak tidak lagi statis?

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.

### Apakah Ikiform gratis?

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.

## Panduan lainnya

- [Hentikan spam formulir kontak](https://ikiform.com/id/guides/spam-protection.md): Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.
- [Cloudflare Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md): Tambahkan Cloudflare Turnstile ke formulir HTML biasa: widget, siteverify di sisi server, key uji, dan cara menjalankannya tanpa key atau server sendiri.
- [Rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md): Batasi rate kiriman formulir per pengunjung dengan store bersama, jawab 429 dengan Retry-After, dan hindari mengunci kantor yang berbagi satu IP.
- [Field honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md): Tambahkan honeypot ke formulir apa pun yang menangkap bot tanpa mengganggu screen reader, keyboard, atau autofill, dengan pemeriksaan server untuk setiap stack.
- [Tanpa captcha](https://ikiform.com/id/guides/spam-protection/without-captcha.md): Hentikan sebagian besar spam formulir tanpa captcha sama sekali: honeypot, waktu render bertanda tangan, skor konten, dan rate limit, digabungkan alih-alih dipakai sendiri-sendiri.
- [Alternatif reCAPTCHA](https://ikiform.com/id/guides/spam-protection/recaptcha-alternatives.md): Bandingkan alternatif reCAPTCHA untuk formulir, dari Turnstile dan hCaptcha hingga proof-of-work dan tanpa captcha sama sekali, lalu beralih dengan perubahan kecil yang aman.
