# Menambahkan formulir kontak ke situs Cloudflare Pages

> Tambahkan formulir kontak ke Cloudflare Pages tanpa menulis Function: kirim ke form backend, atur header di _headers, dan pakai key Turnstile Anda sendiri.

Terbit 2026-10-25 · https://ikiform.com/id/guides/contact-form-static-site/cloudflare-pages

Aset statis di Cloudflare Pages menjawab `POST` dengan `405`, jadi tulis Pages Function atau kirim formulir ke form backend. Dengan Ikiform Anda tidak menulis Function: atur `action` formulir ke endpoint Anda, tambahkan `your-project.pages.dev` ke **Allowed domains**, yang juga mencakup setiap URL preview, dan cantumkan Ikiform di Content-Security-Policy `_headers` Anda. Jika Anda sudah memakai Cloudflare, Anda bisa memasang key Turnstile sendiri ke formulir.

## Apa yang berbeda di Cloudflare Pages

Pages menyajikan hasil build Anda dari jaringan Cloudflare. Aset statis menjawab `GET`; `POST` ke aset mendapat `405` tanpa body:

`Respons untuk POST`:

```http
HTTP/2 405
server: cloudflare
content-length: 0
```

Pages bisa menjalankan kode, lewat Pages Functions di folder `functions/`. `functions/contact.ts` bisa menerima kiriman, tetapi berarti Anda menulis backend: parsing, validasi, penyedia email, penyaringan spam, dan penyimpanan. Dokumentasi Cloudflare sendiri juga menyarankan memulai proyek baru di Workers, bukan Pages. Mengirim ke form backend menghindari keduanya, dan formulir yang sama tetap berfungsi tanpa perubahan jika nanti Anda pindah ke static assets Workers.

| Ciri Pages | Artinya bagi formulir |
| --- | --- |
| URL preview berupa subdomain, seperti `abc123.your-project.pages.dev` | Satu domain yang diizinkan, `your-project.pages.dev`, mencakup production dan setiap preview. Preview Vercel dan Netlify perlu entri sendiri |
| Header berasal dari file `_headers` di hasil build | Hingga 100 aturan, 2.000 karakter per baris. Tidak berlaku untuk respons dari Pages Functions |
| Turnstile adalah produk Cloudflare | Anda bisa memakai pemeriksaan terkelola Ikiform tanpa key, atau widget sendiri dari dashboard Cloudflare |

Diagram: Formulir kontak di Cloudflare Pages dengan key Turnstile Anda sendiri

1. Pages menyajikan halaman kontak, dengan Content-Security-Policy dari file _headers Anda.
2. Widget Turnstile berjalan dengan site key Anda dan menaruh token di formulir.
3. Formulir mengirim field dan token ke Ikiform.
4. Ikiform memverifikasi token dengan secret key Anda dan mencocokkan hostname token dengan domain yang diizinkan.
5. Kiriman yang valid disimpan dan dikirim lewat email. Token yang hilang, kedaluwarsa, atau sudah dipakai mendapat 403 captcha_failed, dan halaman me-reset widget.

Jika memakai pemeriksaan terkelola Ikiform, tidak ada widget di halaman Anda; formulir HTML sama sekali tidak perlu diubah.

## Formulirnya

Versi ini memakai widget Turnstile Anda sendiri, yang masuk akal jika Anda sudah mengelola Turnstile di akun Cloudflare. Di pengaturan formulir, atur **Spam check** ke **Your own Turnstile keys** lalu tempel site key dan secret-nya. Widget menambahkan field `cf-turnstile-response` ke formulir, dan select `team` memberi tahu siapa yang sebaiknya menjawab:

`public/contact.html`:

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>
```

Skrip menjaga pengunjung tetap di halaman. Token Turnstile hanya sekali pakai, jadi setelah pengiriman gagal, skrip me-reset widget untuk percobaan berikutnya:

`public/contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
```

- Di pengaturan widget Turnstile di Cloudflare, cantumkan hostname tempat formulir berjalan. Ikiform juga mencocokkan hostname token dengan **Allowed domains** formulir.
- Kiriman tanpa token valid ditolak dengan `403 captcha_failed`. Termasuk kiriman HTML biasa yang dikirim sebelum widget selesai.
- Tidak ingin key sama sekali? Atur **Spam check** ke **Ikiform**, hapus widget dan skripnya, dan kiriman HTML menjalankan pemeriksaan di ikiform.com. Versi `fetch` lalu butuh `challenge()` dari [SDK](https://ikiform.com/id/docs/sdk.md); lihat [pemeriksaan manusia](https://ikiform.com/id/docs/spam.md#human-check).

## Key Anda atau pemeriksaan Ikiform

|   | Pemeriksaan Ikiform | Key Turnstile Anda sendiri |
| --- | --- | --- |
| Key yang dikelola | Tidak ada | Site key dan secret dari dashboard Cloudflare Anda |
| Perubahan pada formulir HTML | Tidak ada. Pemeriksaan berjalan di halaman ikiform.com | Tambahkan widget dan skripnya |
| Versi `fetch` | Panggil `challenge()` dari SDK | Kirim `cf-turnstile-response` dari widget |
| Content-Security-Policy | Hanya `form-action` dan `connect-src` untuk Ikiform | Juga `challenges.cloudflare.com` di `script-src` dan `frame-src` |
| Analitik Turnstile | Tidak ada di akun Anda | Di dashboard Cloudflare Anda, di samping widget lainnya |

Pemeriksaan Ikiform lebih sedikit setup-nya. Key sendiri masuk akal jika Anda sudah memantau analitik Turnstile, atau ingin tampilan dan posisi widget sepenuhnya Anda kendalikan.

### Menguji widget secara lokal

Cloudflare menerbitkan site key dan secret untuk pengujian yang selalu lolos atau selalu gagal, seperti pasangan always-pass `1x00000000000000000000AA` dan `1x0000000000000000000000000000000AA`. Secret production menolak token yang dibuat dengan key pengujian, jadi jangan campur keduanya di satu formulir. Buat formulir Ikiform kedua untuk pengembangan lokal, beri pasangan key pengujian, dan ganti `data-sitekey` dan ID formulir bersamaan. Biarkan **Allowed domains** formulir itu kosong: Ikiform hanya memeriksa hostname token jika daftarnya diisi, dan Cloudflare tidak mendokumentasikan hostname mana yang dilaporkan token pengujian.

## File _headers

Taruh `_headers` di direktori aset statis Anda, seperti `public/`, agar ikut masuk ke hasil build. Turnstile butuh `script-src` dan `frame-src` untuk `https://challenges.cloudflare.com`, dan Ikiform butuh `form-action` dan `connect-src`:

`public/_headers`:

```text
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Jika nanti Anda menambahkan Pages Function ke proyek yang sama, responsnya tidak mendapat header ini. Atur di kode Function-nya.

## Jika Anda pindah ke Workers

Memindahkan situs ke static assets Workers mengubah cara deploy, bukan cara kerja formulir. Formulir tetap mengirim ke ikiform.com, jadi tidak ada yang menanganinya di sisi Cloudflare. Cek dua hal setelah pindah: host `workers.dev` atau route baru ada di **Allowed domains**, dan header Anda masih sampai ke halaman, karena Workers punya cara sendiri untuk mengaturnya.

> Tambahkan `your-project.pages.dev` dan domain kustom Anda di **Allowed domains**. Jangan pernah menambahkan `pages.dev` begitu saja: setiap proyek Pages akan cocok.

## Pastikan berfungsi

1. Push sebuah branch dan buka URL preview-nya. Kirim, lalu pastikan pesannya masuk dan tidak ditandai spam: preview adalah subdomain dari host proyek Anda.
2. Kirim lagi di production, lalu tekan kembali dan kirim formulir yang sama untuk kedua kalinya tanpa menyelesaikan widget. Yang diharapkan adalah pesan reset, bukan kiriman yang tersimpan.
3. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
4. Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan **Reply** terkirim ke pengirim formulir jika formulir punya field `email`.
5. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `405` dengan body kosong | Formulir mengirim ke URL Pages. Cek `action` yang di-deploy, dan pastikan tidak ada Function atau aturan redirect yang menulis ulang `/contact`. |
| Widget Turnstile tidak pernah muncul | Content-Security-Policy memblokir `challenges.cloudflare.com`. Tambahkan ke `script-src` dan `frame-src`, dan cek `_headers` ada di hasil build. |
| `403 captcha_failed` di setiap kiriman | Site key dan secret berasal dari widget yang berbeda, hostname tidak tercantum di widget, atau secret pengujian dipasangkan dengan site key sungguhan. |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |

## Pertanyaan

### Apakah saya butuh Pages Functions untuk formulir kontak di Cloudflare Pages?

Tidak. Function adalah salah satu cara menerima kiriman, tetapi Anda lalu menulis validasi, pengiriman email, dan penyaringan spam sendiri. Mengirim ke form backend menjaga proyek tetap sepenuhnya statis.

### Apakah deployment preview butuh domain yang diizinkan sendiri?

Tidak. URL preview Pages adalah subdomain dari your-project.pages.dev, jadi mengizinkan host itu sudah mencakup semuanya.

### Bisakah saya memakai widget Turnstile yang sudah ada dengan Ikiform?

Bisa. Atur spam check formulir ke key Turnstile Anda sendiri lalu tempel site key dan secret-nya. Ikiform memverifikasi setiap token dan mencocokkan hostname-nya dengan domain yang diizinkan.

### Mengapa formulir saya mendapat 405 di Cloudflare Pages?

Formulir mengirim ke URL Pages Anda sendiri, yang menyajikan aset statis yang hanya menjawab request GET. Arahkan action formulir ke form backend, atau tambahkan Pages Function untuk path itu.

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

- [Formulir kontak di situs statis](https://ikiform.com/id/guides/contact-form-static-site.md): Pasang formulir kontak yang berfungsi di situs statis tanpa server: ke mana POST dikirim, spam, balasan lewat email, dan kode untuk setiap host dan framework.
- [Vercel](https://ikiform.com/id/guides/contact-form-static-site/vercel.md): Halaman statis di Vercel menjawab POST formulir dengan 405 Method Not Allowed; ini penyebabnya, dan cara mengirim formulir ke backend.
- [Netlify](https://ikiform.com/id/guides/contact-form-static-site/netlify.md): Jalankan formulir kontak di situs Netlify tanpa Netlify Forms: tanpa deteksi saat build, kiriman JSON dari React, dan formulir terpisah untuk deploy preview.
- [GitHub Pages](https://ikiform.com/id/guides/contact-form-static-site/github-pages.md): GitHub Pages tidak bisa menjalankan kode server, jadi POST formulir mendapat 405 Not Allowed; kirim ke form backend dan atur redirect project site dengan benar.
- [Amazon S3](https://ikiform.com/id/guides/contact-form-static-site/aws-s3.md): Endpoint website S3 hanya menerima GET dan HEAD lewat HTTP biasa, jadi kirim formulir kontak ke form backend dan atur redirect dengan benar lewat CloudFront.
- [Website portofolio](https://ikiform.com/id/guides/contact-form-static-site/portfolio-website.md): Formulir kontak portofolio yang menanyakan hal yang biasa Anda tanyakan di panggilan pertama: proyek, budget, waktu, dan link, tanpa server dan dengan balasan dari inbox Anda.
