# Perlindungan spam formulir React Router dengan Turnstile

> Lindungi formulir React Router dari spam: route action dengan Turnstile, rate limit Workers berdasarkan cf-connecting-ip, modul .server, dan header CSP.

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

Lakukan pemeriksaan di `action` route: batasi berdasarkan IP client, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Simpan helper di modul `.server.ts` agar tidak pernah sampai ke browser, dan atur CSP dari export `headers` route. Di Cloudflare Workers, binding rate limiting dan secret berasal dari `env` di `cloudflare:workers`.

## Apa yang berbeda di React Router

Dalam framework mode, setiap route yang punya `action` menerima POST, dari formulir Anda atau dari siapa pun. React Router memberi Anda tiga alat yang menentukan bagaimana pemeriksaan spam ditempatkan:

- **Modul `.server`.** File bernama `spam.server.ts` tidak bisa di-bundle ke kode client, jadi secret dan panggilan siteverify tetap di server meskipun komponen mengimpor dari route yang sama.
- **Export `headers`.** Header yang dikembalikan action tidak dikirim di respons dokumen kecuali fungsi `headers` route meneruskannya. CSP juga ditaruh di sana.
- **`useFetcher`.** Hook ini mengirim ke action tanpa navigasi, jadi halaman dan widget-nya tetap ter-mount di antara percobaan. Itulah sebabnya widget di-mount ulang dengan `key` setelah setiap jawaban.

Halaman ini di-deploy ke Cloudflare Workers, tempat yang umum untuk React Router, yang sudah punya binding rate limiting bawaan. Tabel deploy membahas Vercel dan Netlify.

Diagram: Bagaimana kiriman sampai ke action React Router, dengan dan tanpa JavaScript

1. Setelah hydration, formulir mengirim lewat useFetcher, sehingga halaman tidak bernavigasi.
2. Action membatasi per IP, memverifikasi token Turnstile, dan meneruskan field-nya.
3. Hasilnya tiba sebagai fetcher.data.
4. Key widget berubah, sehingga widget baru, dan token baru, siap untuk percobaan berikutnya.
5. Sebelum hydration, formulir yang sama mengirim sebagai request dokumen biasa, tanpa token Turnstile.
6. Action menolaknya di siteverify.
7. Export headers meneruskan Retry-After dan CSP ke respons dokumen.
8. Komponen membaca actionData alih-alih fetcher.data dan menampilkan error-nya.

Pemeriksaan manusia butuh JavaScript, jadi jalur tanpa JavaScript berakhir dengan error yang jelas, bukan lolos diam-diam.

## Setup proyek

Mulai dari template React Router milik Cloudflare dengan `@cloudflare/vite-plugin`. Di React Router 8 argumen `context` selalu berupa `RouterContextProvider`, dan objek biasa `{ cloudflare: { env } }` dari entry Worker akan melempar error, jadi entry hanya meneruskan request dan route membaca binding dengan `import { env } from "cloudflare:workers"`. File yang disentuh halaman ini:

`File`:

```text
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
```

`workers/app.ts`:

```ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
```

`wrangler.jsonc`:

```json
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
```

`.dev.vars`:

```bash
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

- Site key bersifat publik, jadi diletakkan di `vars`. Loader memberikannya ke komponen, sehingga build client sama sekali tidak butuh environment variable.
- Secret disimpan di `.dev.vars` secara lokal dan lewat `wrangler secret put` di production, jangan pernah di `wrangler.jsonc`.
- Jalankan `wrangler types` setelah mengedit konfigurasi, agar `CONTACT_LIMIT` dan secret punya tipe di `env`.

## Helper khusus server

`app/lib/spam.server.ts`:

```ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
```

- Akhiran `.server` membuat build gagal jika kode client akan menyertakan modul ini.
- Siteverify menerima body form-encoded. Pemeriksaan hostname membandingkan dengan host yang dituju pengunjung dan dilewati saat development, karena token uji membawa hostname tiruan.
- `forward` mengirim `FormData` aslinya, sehingga setiap field, termasuk honeypot, sampai ke Ikiform persis seperti yang dikirim.
- Helper ini juga meneruskan `Origin` pengunjung, karena fetch sisi server tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.

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

## Widget

`app/components/turnstile.tsx`:

```tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}
```

React 19 mengangkat `<script>` `async` ke head dan memuatnya sekali, berapa kali pun komponen dirender. Effect merender widget secara eksplisit, yang direkomendasikan Cloudflare untuk single-page app, dan menghapusnya saat unmount.

## Route

`app/routes/contact.tsx`:

```tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

const POLICY = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <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} />
      <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>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
```

- Limit berjalan sebelum `request.formData()`, jadi banjir request tidak pernah memakan parsing body atau panggilan siteverify.
- `cf-connecting-ip` diatur Cloudflare di setiap request ke Worker dan tidak bisa diatur pengunjung.
- `data()` mengatur status dan `Retry-After`. Fetcher membaca body secara langsung; export `headers` yang membawa header tersebut ke respons dokumen.
- `result` kembali ke `actionData`, sehingga error tetap tampil saat formulir dikirim sebelum hydration.
- Class `.hp` menyembunyikan honeypot di luar layar, seperti di [panduan honeypot](https://ikiform.com/id/guides/spam-protection/honeypot.md).

React Router merender skrip inline untuk hydration, jadi policy ini mempertahankan `'unsafe-inline'` di `script-src`. Untuk menghapusnya, buat nonce di `entry.server.tsx` dan teruskan ke `<Scripts>` dan skrip Turnstile; Cloudflare menyatakan Turnstile meneruskan nonce ke apa yang dimuatnya.

## Uji secara lokal

Dokumentasi Cloudflare menyatakan plugin Vite-nya menjalankan kode server Anda di runtime Workers selama development, jadi `env` dari `cloudflare:workers`, `.dev.vars`, dan binding rate limiting berperilaku seperti di production. Secara lokal tidak ada edge Cloudflare yang mengatur `cf-connecting-ip`, jadi setiap request dihitung sebagai `unknown` dan berbagi satu limit. Itu memang diharapkan. Untuk melihat header aslinya, deploy preview dengan `wrangler versions upload` dan uji dari dua jaringan.

## Deploy ke tempat lain

| Hosting | IP client | Limiter | Secret |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | Binding rate limiting | `wrangler secret put` |
| Vercel | `x-forwarded-for`, ditimpa oleh Vercel | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare mendokumentasikan binding ini sebagai perkiraan dan dihitung per lokasi, yang tidak masalah untuk formulir kontak. Di luar Workers, tidak ada modul `cloudflare:workers`, jadi baca secret dari `process.env` dan ganti binding dengan penghitung Redis seperti di [halaman rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md).

## Biarkan Ikiform menjalankan pemeriksaannya

Jika mengelola key, binding, dan CSP untuk satu formulir terasa berlebihan, arahkan formulir biasa ke endpoint Ikiform Anda dan hapus action-nya:

`app/routes/contact.tsx`:

```tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <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 />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>
```

Pakai `<form>` huruf kecil, bukan `<Form>` milik React Router, agar browser mengirim langsung ke Ikiform. Atur **Spam check** ke **Ikiform**: kiriman tanpa token mendapat halaman pemeriksaan singkat di ikiform.com yang menjalankan Turnstile dan meneruskan field-nya. Pilih **Rate limit** Strict, 2 per 10 menit per pengunjung, dan daftarkan domain Anda di **Allowed domains**. Spam yang tertangkap mendapat jawaban yang sama dengan pesan asli dan disimpan selama 30 hari. [Panduan setup server](https://ikiform.com/id/docs/guides/server.md) membahas cara tetap memakai action Anda sendiri.

## Pastikan berfungsi

1. Jalankan `npm run dev` dan kirim pesan dengan test key. Pesan muncul di **Submissions**. Workspace tanpa paket menerima 50 uji dari `localhost` per bulan.
2. Kirim pesan kedua tanpa memuat ulang. Widget baru muncul setelah jawaban pertama dan kiriman kedua berhasil.
3. Matikan JavaScript dan kirim. Halaman dimuat ulang dengan error pemeriksaan manusia.
4. Kirim enam kali dalam satu menit setelah deploy. Kiriman keenam mendapat `429` dengan `Retry-After: 60`.
5. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `Server-only module referenced by client` | Sebuah komponen mengimpor sesuatu dari `spam.server.ts` yang direndernya. Hanya `loader` dan `action` yang boleh memakainya. |
| Percobaan kedua selalu gagal di siteverify | Widget masih memegang token yang sudah terpakai. Pastikan `key`-nya berubah setelah setiap jawaban fetcher. |
| `Invalid context value provided to handleRequest` | Entry Worker masih meneruskan `{ cloudflare: { env, ctx } }`, seperti template React Router 7. Panggil handler hanya dengan request dan baca binding dari `cloudflare:workers`. |
| `Retry-After` tidak ada di respons tanpa JavaScript | Header action dibuang kecuali export `headers` mengembalikannya. Salin dari `actionHeaders` seperti yang ditunjukkan. |

## Pertanyaan

### Bagaimana menambahkan Turnstile ke action React Router?

Render widget di komponen route, baca cf-turnstile-response dari form data di action, lalu kirim bersama secret key Anda ke siteverify. Lanjutkan hanya jika jawabannya menyatakan success dengan action yang Anda atur di widget.

### Bagaimana membuat rate limit untuk action React Router di Cloudflare Workers?

Deklarasikan binding rate limiting di wrangler.jsonc dan panggil limit dengan key dari header cf-connecting-ip di awal action. Kembalikan 429 dengan data() saat gagal.

### Mengapa header dari action saya tidak sampai ke browser?

Pada request dokumen, React Router hanya mengirim header yang dikembalikan export headers route Anda. Baca actionHeaders di sana dan salin yang Anda perlukan, seperti Retry-After.

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