# Perlindungan spam formulir TanStack Start dengan Turnstile

> Lindungi formulir TanStack Start dari spam: function middleware untuk IP client, token bucket, Turnstile yang diverifikasi di server function, dan CSP.

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

Tambahkan function middleware yang membaca IP client dan origin sekali lalu meneruskannya ke setiap server function sebagai context. Di server function, validasi input, ambil satu token dari bucket per IP, verifikasi token Turnstile dengan siteverify, lalu teruskan field-nya bersama honeypot. Pertahankan middleware CSRF yang dipasang Start, dan tambahkan kembali sendiri jika Anda membuat `src/start.ts`.

## Apa yang berbeda di TanStack Start

Server function adalah endpoint RPC same-origin. Start melindunginya dari situs lain dengan middleware CSRF yang memeriksa `Sec-Fetch-Site`, `Origin`, atau `Referer`, dan itu mencegah halaman di tempat lain mengirim lewat browser pengunjung. Tetapi tidak menghentikan skrip, yang bisa mengirim header apa saja. Jadi pemeriksaan spam tetap harus ada di dalam function.

Blok bangunan Start untuk ini adalah middleware. Function middleware berjalan sebelum validator dan handler, bisa membaca header request, dan memberikan nilai ke handler lewat `context`. Tulis pencarian IP sekali dan setiap formulir di situs ikut mendapatkannya.

Diagram: Rantai middleware di depan server function TanStack Start

1. Browser, atau skrip, mengirim ke URL server function.
2. Middleware CSRF milik Start menolak kiriman yang tidak bisa dibuktikan berasal dari origin Anda sendiri.
3. Middleware pengunjung membaca IP client dan header Origin lalu meneruskannya sebagai context.
4. Validator menormalkan input sebelum handler melihatnya.
5. Handler mengambil satu token dari bucket IP tersebut. Jika habis, handler mengembalikan hasil coba-lagi-nanti.
6. Handler memverifikasi token Turnstile, lalu meneruskan field ke Ikiform, yang menyimpannya dan memberi skor pada honeypot.

Setiap lapisan lebih murah dari lapisan berikutnya. Kiriman yang ditolak tidak pernah sampai ke siteverify atau Ikiform.

## Setup proyek

`File`:

```text
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
```

`.env`:

```bash
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
```

- Vite hanya membuka variabel berawalan `VITE_` ke browser, dibaca lewat `import.meta.env`. Secret tidak punya awalan itu, jadi hanya ada di server sebagai `process.env`.
- Test key lolos di host mana pun, termasuk `localhost`. Ganti dengan key asli per environment.

## Function middleware untuk IP client

`src/server/visitor.ts`:

```ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});
```

Atur `CLIENT_IP_HEADER` ke header yang ditimpa hosting Anda: `x-forwarded-for` di Vercel, `x-nf-client-connection-ip` di Netlify, yang direkomendasikan staf Netlify meski dokumentasinya hanya menjelaskan `context.ip` di Functions, dan `cf-connecting-ip` di Cloudflare. Di server Node Anda sendiri di belakang nginx, pakai header yang diatur nginx dan pastikan aplikasi tidak bisa dijangkau tanpa melewatinya. Middleware juga meneruskan `Origin` pengunjung, karena fetch sisi server ke Ikiform tidak mengirimnya dan Ikiform membacanya untuk mengenali uji gratis dari `localhost`.

## Token bucket per IP

Jendela tetap membiarkan skrip mengirim jatah penuh di akhir satu menit dan jatah lain di awal menit berikutnya. Token bucket mengisi ulang satu percobaan setiap kali, jadi orang yang memperbaiki salah ketik hanya menunggu beberapa detik, bukan satu menit:

`src/server/bucket.ts`:

```ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}
```

Penghitung ini disimpan di memori, yang akurat di satu server yang berjalan lama, seperti `node .output/server/index.mjs` di container. Di Vercel atau Netlify setiap instance function menyimpan map-nya sendiri, jadi limit sebenarnya tidak diketahui. Pindahkan bucket ke Redis di sana, seperti yang ditunjukkan [halaman rate limiting](https://ikiform.com/id/guides/spam-protection/rate-limiting.md), dan pertahankan bentuk `take(key)` yang sama.

## Server function

`src/server/support.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  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 }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    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}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
```

- Validator berjalan sebelum handler dan hanya menormalkan input. Error field kembali sebagai hasil yang bisa ditampilkan formulir di samping input yang tepat.
- Pemeriksaan murah lebih dulu: pemeriksaan email dan panjang tidak memakan biaya, bucket memakan satu pencarian map, siteverify memakan satu panggilan jaringan.
- `action` harus `support`. Token yang dibuat untuk widget lain dengan site key Anda akan ditolak.
- Honeypot `_gotcha` diteruskan, tidak diperiksa di sini, sehingga honeypot yang terisi disimpan sebagai spam di tempat Anda bisa meninjaunya.

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

## Route dan widget

Widget dirender secara eksplisit dari hook kecil. Promise di level modul memuat skrip sekali, bahkan saat route di-mount beberapa kali selama navigasi client, dan token tiba lewat `callback` Turnstile:

`src/lib/use-turnstile.ts`:

```ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
```

`src/routes/support.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
```

- Tombol tetap nonaktif sampai Turnstile memberikan token, sehingga tidak ada yang mengirim token kosong.
- `turnstile.reset()` berjalan setelah setiap percobaan. Token hanya berlaku sekali dan hidup selama 300 detik, jadi percobaan ulang butuh token baru.
- Formulir ini butuh JavaScript. Server function bisa menerima kiriman formulir biasa lewat `.url`-nya, tetapi widget Turnstile memang butuh browser.

## Header keamanan di src/start.ts

Start tidak punya opsi CSP sendiri. Request middleware global di `src/start.ts` bisa memasangnya di setiap respons. Membuat file itu mematikan middleware CSRF yang dipasang Start secara default, jadi tambahkan kembali terlebih dulu:

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
```

- HTML hasil server-render Start memuat skrip inline. Tanpa setup nonce, `script-src` butuh `'unsafe-inline'`; policy ini tetap memblokir skrip dari host mana pun yang tidak Anda cantumkan.
- Turnstile menggambar widget-nya di iframe dari `challenges.cloudflare.com`, yang harus diizinkan oleh `frame-src`.
- `filter` CSRF membatasi pemeriksaan hanya pada server function, jadi pemuatan halaman biasa tidak terpengaruh.

## Deploy

| Hosting | CLIENT_IP_HEADER | Limiter |
| --- | --- | --- |
| Server Node atau container Anda sendiri | Apa pun yang diatur proxy Anda | Memori sudah cukup |
| Vercel | `x-forwarded-for` | Redis |
| Netlify | `x-nf-client-connection-ip` | Redis |
| Cloudflare Workers | `cf-connecting-ip` | Binding rate limiting Workers |

Vercel mendokumentasikan bahwa ia menimpa `x-forwarded-for` dengan alamat pengunjung. Daftarkan setiap hostname production dan preview di widget Turnstile sebelum deploy.

## Biarkan Ikiform menjalankan pemeriksaannya

Jika Anda tidak ingin mengelola key Turnstile dan limiter sendiri, hapus server function dan kirim dari browser. Di `onSubmit`, ganti panggilan `send` dengan challenge dan submit, dengan `box` sebagai ref pada `div` kosong yang menggantikan widget:

`src/routes/support.tsx`:

```ts
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });
```

Atur **Spam check** formulir ke **Ikiform**, pilih **Rate limit** Strict 2 per 10 menit per pengunjung, dan tambahkan domain Anda di **Allowed domains**. Ikiform lalu menjalankan honeypot, Turnstile di ikiform.com, limit per IP, dan skor konten, serta menyimpan yang tertangkap selama 30 hari. CSP Anda butuh `https://ikiform.com` di `frame-src` dan `connect-src`, bukan Cloudflare. [Panduan setup server](https://ikiform.com/id/docs/guides/server.md) membahas key jika Anda tetap memakai server function.

## Pastikan berfungsi

1. Jalankan dev server dengan test key dan kirim request. Request muncul di **Submissions**. Tanpa paket, 50 uji dari `localhost` per bulan gratis.
2. Kirim empat request dengan cepat. Yang keempat mengembalikan pesan coba-lagi-nanti dengan jumlah detik.
3. Atur `TURNSTILE_SECRET_KEY` ke secret yang selalu gagal. Setiap request menampilkan error pemeriksaan manusia.
4. Ulangi panggilan server function dari tab network dengan `curl` dan tanpa `Origin`. Middleware CSRF menolaknya.
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 |
| --- | --- |
| Kiriman lintas situs sampai ke server function setelah menambahkan `src/start.ts` | `src/start.ts` kustom menggantikan middleware CSRF default. Tambahkan `createCsrfMiddleware()` ke `requestMiddleware`, seperti yang ditunjukkan. |
| Semua pengunjung berbagi satu bucket | `CLIENT_IP_HEADER` menyebut header yang tidak diatur hosting Anda, sehingga setiap IP menjadi `unknown`. Pakai header dari tabel di atas. |
| Limit tampak ter-reset secara acak di Vercel | Setiap instance function punya memorinya sendiri. Pindahkan bucket ke Redis. |
| Widget dirender dua kali saat development | Strict Mode React me-mount effect dua kali. Cleanup menghapus widget pertama, jadi production hanya menampilkan satu. |

## Pertanyaan

### Bagaimana mendapatkan IP client di server function TanStack Start?

Baca header yang diatur hosting Anda dengan getRequestHeader dari @tanstack/react-start/server, idealnya sekali di function middleware yang meneruskannya ke handler sebagai context. Pakai x-forwarded-for di Vercel, x-nf-client-connection-ip di Netlify, dan cf-connecting-ip di Cloudflare.

### Apakah server function TanStack Start punya perlindungan CSRF?

Ya. Start memasang middleware CSRF untuk server function kecuali Anda mendefinisikan src/start.ts, dan dalam hal itu Anda menambahkan createCsrfMiddleware sendiri. Middleware ini memblokir kiriman browser lintas situs, bukan skrip, jadi pemeriksaan spam tetap diperlukan.

### Bisakah saya membuat rate limit TanStack Start di memori?

Di satu server Node yang berjalan lama, bisa. Di hosting serverless setiap instance punya memorinya sendiri, jadi pakai penyimpanan bersama seperti Redis atau binding rate limiting Cloudflare.

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