# Menambahkan formulir kontak ke situs statis tanpa backend

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

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

Host statis menyajikan file dan menolak POST formulir, biasanya dengan `405` atau `404`, jadi formulir harus mengirim ke tempat lain. Arahkan `action` formulir ke form backend seperti Ikiform, yang menyimpan setiap pesan, menyaring spam, dan mengirimi Anda email dengan pengirim sebagai alamat reply-to. Situs Anda tetap statis: tanpa kode server, tanpa secret key di halaman, dan tanpa perubahan cara deploy.

Situs statis adalah folder berisi HTML, CSS, dan JavaScript yang dibagikan host sebagai file. Itulah sebabnya ia cepat, murah, dan sulit rusak. Itu juga sebabnya formulir kontak menjadi hal pertama yang berhenti berfungsi: kiriman formulir adalah request yang harus diterima oleh sesuatu, dan host statis tidak punya apa pun yang mendengarkannya.

## Mengapa formulir gagal di host statis

`<form method="post">` tanpa `action` akan mengirim ke halaman tempat ia berada. Di host statis, halaman itu berupa file, dan file hanya menjawab `GET` dan `HEAD`. Kami mengirim `POST` ke halaman statis live di setiap host besar pada 2026-10-25. Tidak satu pun menyimpan apa-apa:

| Host | Hasil POST ke halaman statis |
| --- | --- |
| GitHub Pages | `405 Not Allowed`, berupa halaman HTML kecil |
| Vercel | `405` dengan body kosong |
| Netlify | `404`, dengan halaman 404 situs jika ada, kecuali Netlify Forms mendeteksi formulir itu saat deploy |
| Cloudflare Pages | `405` dengan body kosong |
| Endpoint website Amazon S3 | `405 Method Not Allowed`, kode `MethodNotAllowed` |

Solusinya sama di mana pun: kirim kiriman ke URL yang menerimanya. URL itu bisa berupa kode yang Anda tulis dan host sendiri, fitur dari host Anda, atau form backend. Sisa panduan ini memakai form backend, karena situs tetap statis.

Diagram: Ke mana kiriman formulir kontak pergi, sebelum dan sesudah diperbaiki

1. Tanpa action, atau dengan action yang mengarah ke path di situs Anda sendiri, browser mengirim ke host statis.
2. Host tidak punya kode untuk dijalankan saat menerima POST, jadi ia menjawab 405 atau 404 dan pesannya hilang.
3. Dengan action yang mengarah ke endpoint Ikiform Anda, kiriman masuk ke server yang menerimanya.
4. Ikiform memeriksa spam pada pesan, menyimpannya, dan mengirimi Anda email.
5. Ikiform menjawab dengan redirect 303, dan browser memuat halaman terima kasih di situs Anda sendiri.

Hanya action formulir yang berubah. Halaman, host, dan deploy tetap seperti semula.

## Apa yang terjadi pada setiap pesan

Ikiform adalah endpoint tujuan kiriman formulir. Ikiform tidak meng-host halaman Anda atau merender formulir Anda; keduanya tetap milik Anda. Untuk setiap kiriman, Ikiform menjalankan pemeriksaan spam, menyimpan pesan, dan meneruskannya ke tempat Anda membacanya.

Diagram: Apa yang dilakukan Ikiform dengan satu pesan formulir kontak

1. Pengunjung mengisi formulir kontak, lalu formulir mengirimnya ke endpoint Ikiform Anda.
2. Ikiform memeriksa honeypot, rate limit, dan skor spam, lalu menyimpan pesannya.
3. Setiap penerima notifikasi mendapat email, dengan reply-to diatur ke alamat pengirim.
4. Pesan muncul di dashboard, tempat Anda bisa mencarinya, memulihkan spam, dan mengekspor CSV.
5. Jika Anda menambahkan webhook, Ikiform mengirim salinan bertanda tangan ke Slack, Discord, alat otomasi, atau kode Anda sendiri.

Email sudah cukup untuk kebanyakan formulir kontak. Tambahkan webhook jika pesan perlu masuk ke tempat Anda biasa bekerja.

## Lima cara menerima formulir dari situs statis

| Pendekatan | Unggul dalam | Kekurangan |
| --- | --- | --- |
| Link `mailto:` atau `action="mailto:..."` | Tanpa setup. Cukup untuk halaman pribadi dengan sedikit pengunjung. | Membuka aplikasi email pengunjung, jika mereka sudah mengaturnya. Tidak ada yang disimpan atau disaring, dan alamat Anda ada di HTML untuk dipanen scraper. |
| Fitur formulir dari host Anda, seperti [Netlify Forms](https://ikiform.com/alternatives/netlify-forms) | Satu atribut, tanpa akun lain, dan biasanya sudah termasuk dalam paket hosting. | Hanya berfungsi di host itu. Pindah host berarti membangun ulang formulir, dan formulir yang dirender JavaScript butuh trik tambahan. |
| Serverless function yang Anda tulis sendiri | Kendali penuh atas validasi, penyimpanan, dan email yang Anda kirim. | Sekarang Anda mengelola backend: API key email, penyaringan spam, rate limit, percobaan ulang, dan log. Format function di tiap host berbeda. |
| Embed formulir hosted, seperti [Google Forms](https://ikiform.com/alternatives/google-forms), [Tally](https://ikiform.com/alternatives/tally), atau [Typeform](https://ikiform.com/alternatives/typeform) | Tanpa kode. Cocok untuk formulir panjang atau bertahap. | Iframe atau link ke situs lain, dengan tampilan dan skrip mereka di halaman Anda. |
| Form backend, seperti Ikiform, [Formspree](https://ikiform.com/alternatives/formspree), [Basin](https://ikiform.com/alternatives/basin), atau [Web3Forms](https://ikiform.com/alternatives/web3forms) | HTML Anda sendiri di host mana pun. Penyimpanan, penyaringan spam, dan email tanpa server. | Satu akun dan satu tagihan lagi, dan data formulir disimpan di layanan itu. |

Jika Anda di Netlify dan tidak berencana pindah, Netlify Forms sulit dikalahkan soal kemudahan. Jika Anda ingin menulis backend sendiri, function adalah proyek akhir pekan yang menyenangkan. Jika Anda ingin formulir tetap berfungsi saat berganti host atau framework, pakai form backend. [Perbandingan](https://ikiform.com/alternatives) menyandingkan berbagai backend.

## Formulirnya

Semua contoh di bawah mengirim dari browser, karena deploy statis tidak punya server untuk mengirim. Pilih stack Anda; pilihan itu berlaku untuk setiap contoh kode di panduan ini. Static site generator punya halaman setup sendiri: [Jekyll](https://ikiform.com/id/docs/guides/jekyll.md), [Hugo](https://ikiform.com/id/docs/guides/hugo.md), [Eleventy](https://ikiform.com/id/docs/guides/eleventy.md), dan [Gatsby](https://ikiform.com/id/docs/guides/gatsby.md).

**HTML**

Hanya ini integrasi yang dibutuhkan untuk situs yang ditulis manual. Formulir mengirim dengan cara biasa, jadi tetap berfungsi saat JavaScript mati, dan browser mendarat di `thanks.html` Anda setelah berhasil terkirim.

`contact.html`:

```html
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" maxlength="120" required />
  <p class="field-error" data-error-for="name" hidden></p>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <p class="field-error" data-error-for="email" hidden></p>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
  <p class="field-error" data-error-for="message" hidden></p>

  <input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
  <p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>

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

Skripnya opsional. Skrip menjaga pengunjung tetap di halaman, menaruh setiap error field di bawah inputnya, dan membiarkan pesan yang sudah diketik tetap ada saat terjadi kegagalan.

`contact.js`:

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

function showErrors(fields = {}) {
  for (const slot of form.querySelectorAll("[data-error-for]")) {
    const name = slot.dataset.errorFor;
    const message = fields[name]?.[0] ?? "";
    slot.textContent = message;
    slot.hidden = !message;
    form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  status.textContent = "Sending";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.hidden = true;
      done.hidden = false;
      return;
    }
    showErrors(result.error.fields);
    status.textContent = result.error.message;
  } catch {
    status.textContent = "Couldn't send. Check your connection and try again.";
  }
  button.disabled = false;
});
```

**Next.js**

Dengan `output: "export"`, `next build` menulis file biasa ke `out/`. Server Actions dan Route Handlers yang membaca request tidak didukung dalam static export, jadi formulir mengirim dari browser.

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

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

```tsx
"use client";

import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;

export function ContactForm() {
  const [sentTo, setSentTo] = useState("");
  const [sending, setSending] = useState(false);
  const [errors, setErrors] = useState<Errors>({});
  const [notice, setNotice] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setSending(true);
    setNotice("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) {
        setSentTo(String(body.get("email")));
        return;
      }
      const fields = result.error.fields ?? {};
      setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
      setNotice(result.error.message);
    } catch {
      setNotice("Couldn't send. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
      {errors.name ? <p>{errors.name}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
      {errors.email ? <p>{errors.email}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
      {errors.message ? <p>{errors.message}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {notice ? <p role="alert">{notice}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Render `<ContactForm />` dari `app/contact/page.tsx`. Formulir tetap punya `action`, jadi tetap bisa mengirim sebelum bundle JavaScript dimuat.

**Astro**

Astro melakukan prerender setiap halaman secara default, dan itulah yang dibutuhkan host statis. Jangan pakai Astro Actions di sini: mengirim formulir ke action butuh rendering on-demand dan adapter. Kirim ke Ikiform, dan biarkan `<script>` yang di-bundle menjaga pengunjung tetap di halaman.

`src/pages/contact.astro`:

```astro
---
import Layout from "../layouts/Layout.astro";

const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---

<Layout title="Contact">
  <form id="contact" action={endpoint} 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="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required></textarea>
    <input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send message</button>
    <output id="contact-status" role="status"></output>
  </form>
</Layout>

<script>
  const form = document.querySelector<HTMLFormElement>("#contact")!;
  const status = document.querySelector<HTMLOutputElement>("#contact-status")!;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.value = "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: "Thanks. Your message is on its way, and I'll reply by email." }));
      return;
    }
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.entries(fields)[0];
    status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
  });
</script>
```

`Astro.site` berasal dari `site` di `astro.config.mjs`. Atur ke alamat `https:` Anda agar nilai `_redirect` berupa URL lengkap yang bisa diikuti Ikiform.

**TanStack Start**

Mode SPA membangun shell yang dirender di klien yang bisa disajikan host statis mana pun. Server function tetap butuh server, jadi deploy statis tidak bisa menjalankannya; route mengirim dari browser dan pindah ke `/thanks` saat berhasil.

`vite.config.ts`:

```ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      spa: {
        enabled: true,
      },
    }),
    viteReact(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const navigate = useNavigate();
  const [problems, setProblems] = useState<string[]>([]);
  const [sending, setSending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) {
        await navigate({ to: "/thanks" });
        return;
      }
      const fields: Record<string, string[]> = result.error.fields ?? {};
      setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
    } catch {
      setProblems(["Couldn't send. Check your connection and try again."]);
    }
    setSending(false);
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      {problems.length ? (
        <ul role="alert">
          {problems.map((problem) => (
            <li key={problem}>{problem}</li>
          ))}
        </ul>
      ) : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <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={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**React Router**

Dengan `ssr: false` tidak ada build server, jadi route tidak bisa punya `action` server. Sebagai gantinya, `clientAction` berjalan di browser: ia meneruskan kiriman `<Form>` ke Ikiform dan me-redirect saat berhasil.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export async function clientAction({ request }: Route.ClientActionArgs) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: await request.formData(),
  });
  const result = await response.json();
  if (result.ok) return redirect("/thanks");
  return {
    fields: (result.error.fields ?? {}) as Record<string, string[]>,
    message: String(result.error.message),
  };
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";
  const error = (name: string) => actionData?.fields[name]?.[0];

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name") ? <p>{error("name")}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email") ? <p>{error("email")}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      {error("message") ? <p>{error("message")}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

**SvelteKit**

Dengan `adapter-static` dan `prerender = true`, setiap halaman berupa file. Form action SvelteKit tidak bisa dipakai: halaman dengan action tidak bisa di-prerender, karena server harus menangani POST-nya. Komponen mengirim dengan `fetch` sebagai gantinya.

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

  let phase = $state<"idle" | "sending" | "sent">("idle");
  let errors = $state<Record<string, string[]>>({});
  let notice = $state("");

  async function send(event: SubmitEvent) {
    event.preventDefault();
    phase = "sending";
    notice = "";
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget as HTMLFormElement),
      });
      const result = await response.json();
      if (result.ok) {
        phase = "sent";
        return;
      }
      errors = result.error.fields ?? {};
      notice = result.error.message;
    } catch {
      notice = "Couldn't send. Check your connection and try again.";
    }
    phase = "idle";
  }
</script>

{#if phase === "sent"}
  <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
  <form action={ENDPOINT} method="post" onsubmit={send}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
    {#if errors.name}<p>{errors.name[0]}</p>{/if}
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
    {#if errors.email}<p>{errors.email[0]}</p>{/if}
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
    {#if errors.message}<p>{errors.message[0]}</p>{/if}
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if notice}<p role="alert">{notice}</p>{/if}
    <button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

**Nuxt**

`nuxt generate` menulis halaman hasil prerender ke `.output/public`. Route di `server/api` tidak ada di host statis, jadi komponen memanggil Ikiform langsung dengan `$fetch` dan pindah ke `/thanks` saat berhasil.

`app/pages/contact.vue`:

```vue
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);

async function send() {
  sending.value = true;
  notice.value = "";
  try {
    await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
    await navigateTo("/thanks");
  } catch (failure) {
    const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
    errors.value = data?.error?.fields ?? {};
    notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" name="name" autocomplete="name" required />
    <p v-if="errors.name">{{ errors.name[0] }}</p>
    <label for="email">Email</label>
    <input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
    <p v-if="errors.message">{{ errors.message[0] }}</p>
    <input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="notice" role="alert">{{ notice }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

**React**

Single-page app Vite di host statis. Endpoint disimpan di variabel `VITE_` agar build preview dan production bisa mengarah ke formulir berbeda; nilainya tetap masuk ke bundle, dan itu tidak masalah, karena bukan rahasia.

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };

export function ContactForm() {
  const [status, setStatus] = useState<Status>({ kind: "idle" });

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setStatus({ kind: "sending" });
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) return setStatus({ kind: "sent" });
      const fields: Record<string, string[]> = result.error.fields ?? {};
      const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
      setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
    } catch {
      setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
    }
  }

  if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <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={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status.kind === "failed" ? (
        <div role="alert">
          {status.lines.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      ) : null}
      <button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

Kiriman browser yang sama dalam single-file component Vue, membaca endpoint dari variabel `VITE_` yang sama. Hanya error pertama yang ditampilkan, di samping tombol.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { computed, ref } from "vue";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);

async function send(event: SubmitEvent) {
  sending.value = true;
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    sent.value = result.ok;
    fields.value = result.ok ? {} : (result.error.fields ?? {});
    notice.value = result.ok ? "" : result.error.message;
  } catch {
    notice.value = "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="firstError" role="alert">{{ firstError }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Ganti `frm_xxxxxxxxxxxxxxxx` dengan ID formulir Anda dari dashboard. ID ini memang publik: ia ada di HTML Anda begitu Anda deploy. Jangan pernah menaruh secret API key di situs statis.
- Pertahankan nama field email sebagai `email`. Ikiform menjadikannya alamat reply-to email notifikasi dan memeriksanya terhadap domain email sekali pakai.
- Input tersembunyi `_gotcha` adalah honeypot. Orang tidak pernah melihatnya; bot yang mengisi setiap input menandai dirinya sendiri sebagai spam.
- Versi JavaScript mengirim `Accept: application/json`, sehingga Ikiform menjawab `201` saat berhasil, atau `422` dengan objek `error.fields` yang menyebutkan setiap field dan masalahnya.

Jika framework Anda memang berjalan di server saat production, Anda bisa mengirim dari sana dan menjauhkan endpoint dari halaman. Panduan [Next.js](https://ikiform.com/id/docs/guides/nextjs.md), [SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md), dan [Nuxt](https://ikiform.com/id/docs/guides/nuxt.md) menunjukkan versi server-nya, dan [Dari server](https://ikiform.com/id/docs/guides/server.md) membahas secret key.

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

## Field untuk formulir kontak

Nama, email, dan pesan sudah mencakup hampir semua formulir kontak. Pilihan topik layak ditambahkan jika jenis pesan yang berbeda dijawab oleh orang yang berbeda. Coba formulir di bawah: formulir menjalankan validator Ikiform di browser Anda dan menampilkan apa yang akan disimpan, atau `422` yang akan Anda terima.

Formulir: Contact

- **Nama** (`name`, text, wajib)
- **Email** (`email`, email, wajib)
- **Topik** (`topic`, select)
- **Pesan** (`message`, textarea, wajib)

Definisinya sama dengan [template formulir kontak](https://ikiform.com/forms/contact-form). Tempel di tab **Fields** formulir dan Ikiform menerapkannya di server, apa pun isi HTML-nya.

| Field | Alasan | Perhatikan |
| --- | --- | --- |
| `name` | Cara Anda menyapa mereka di balasan | Link di field nama adalah sinyal spam yang kuat, jadi pertahankan ID `name` |
| `email` | Tujuan balasan | Wajib. `type="email"` memunculkan keyboard ponsel yang tepat |
| `topic` | Mengarahkan penjualan dan dukungan ke orang yang tepat | Select hanya menerima nilai yang tercantum, jadi aman untuk difilter |
| `message` | Inti dari formulir ini | Panjang minimum 10 mencegah kiriman satu kata |

Jangan sertakan nomor telepon, ukuran perusahaan, dan apa pun yang tidak akan Anda pakai di balasan pertama. Setiap field tambahan mengurangi jumlah pesan. Tanpa definisi field, Ikiform menyimpan input bernama apa pun yang dikirim formulir; dengan definisi, Ikiform menolak kiriman yang tidak cocok.

> Upload file belum didukung. Kiriman berisi file mendapat `422 files_not_supported`.

## Membalas dari inbox Anda

Email notifikasi dikirim ke maksimal 10 penerima terkonfirmasi per formulir. Jika formulir punya field `email`, reply-to email itu adalah pengirimnya, jadi **Reply** di aplikasi email Anda langsung membalas ke mereka. Anda tidak perlu menyalin alamat dari dashboard.

Notifikasi dibatasi 50 email per formulir per jam, lalu satu ringkasan per jam, sehingga gelombang spam tidak bisa membanjiri inbox Anda. Pesan yang ditandai spam sama sekali tidak mengirim email. `_subject` tidak mengubah subjek email notifikasi. Nilainya diteruskan ke webhook dan API sebagai `meta.subject`.

> Ikiform tidak mengirim email ke orang yang mengisi formulir. Email notifikasi hanya dikirim ke Anda dan tim, jadi tidak ada autoresponder, email konfirmasi, atau double opt-in. Kirim semua itu dari alat email Anda, dipicu oleh [webhook](https://ikiform.com/id/docs/webhooks.md).

Ingin pesan masuk ke chat tim, sebagai pengganti atau tambahan email? Tambahkan webhook dan ikuti panduan [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md), atau [Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md).

## Yang dilihat pengunjung setelah mengirim

Kiriman HTML biasa berakhir dengan redirect. Ikiform menjawab kiriman browser yang berhasil dengan `303 See Other`, dan browser memuat tujuannya dengan `GET`, yang dengan senang hati disajikan host statis Anda.

Diagram: Tiga request di balik satu kiriman formulir HTML biasa

1. Browser mengirim field formulir ke endpoint Ikiform Anda.
2. Ikiform menyimpan pesan dan menjawab 303 See Other dengan header Location.
3. Browser mengikuti redirect dengan GET ke halaman terima kasih Anda, yang disajikan host statis seperti file lainnya.

Tanpa tujuan redirect, langkah 3 memuat halaman terima kasih hosted milik Ikiform, bukan halaman Anda.

Ikiform memilih tujuan dengan urutan ini:

- **Redirect after submit** di pengaturan formulir. URL `http:` atau `https:` apa pun bisa dipakai, karena hanya tim Anda yang bisa mengaturnya. Ini pilihan paling sederhana untuk satu situs.
- Field `_redirect` di formulir, jika berupa URL `https:` di salah satu **Allowed domains** formulir. Pakai ini jika satu formulir melayani beberapa situs atau bahasa.
- Selain itu, halaman terima kasih hosted milik Ikiform, yang menautkan kembali ke situs Anda jika kiriman berasal dari halaman `https:` yang diizinkan.

Jadikan halaman terima kasih sebagai file sungguhan dan jauhkan dari hasil pencarian. Sebutkan kapan Anda akan membalas; itu mencegah email kedua dari orang yang sama.

`thanks.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>Message sent</title>
  </head>
  <body>
    <h1>Thanks, your message is on its way</h1>
    <p>I read every message and reply within two working days, from the address on this site.</p>
    <p><a href="/">Back to the home page</a></p>
  </body>
</html>
```

Versi JavaScript di atas tidak memakai redirect. Kiriman dengan `Accept: application/json` mendapat JSON sebagai jawaban, dan halaman menampilkan kalimat sukses sendiri.

## Spam di formulir statis

Endpoint Anda ada di source halaman, dan bot membaca source halaman. Bersiaplah menerima spam dalam beberapa hari setelah live. Ikiform menyusun perlindungannya berlapis sehingga Anda bisa mulai tanpa setup apa pun:

| Perlindungan | Yang dihentikan | Setup |
| --- | --- | --- |
| Honeypot | Bot yang mengisi setiap input | Input tersembunyi `_gotcha` |
| Skor spam | Link di nama (+3), banyak link (+2), frasa spam yang dikenal (+2), dan alamat sekali pakai. Pada skor 5 pesan disimpan sebagai spam | Tidak ada |
| Rate limit | Skrip yang mengirim berulang-ulang | Aktif secara default: Standard, 5 per menit per pengunjung |
| Domain yang diizinkan | Salinan formulir Anda yang mengirim dari situs lain | Tambahkan domain Anda di pengaturan formulir |
| Pemeriksaan manusia | Bot yang mengendalikan browser sungguhan | Atur **Spam check** ke **Ikiform** untuk Cloudflare Turnstile tanpa key |

Spam mendapat jawaban sukses yang sama dengan pesan asli, jadi bot tidak belajar apa-apa, dan spam tidak mengirim email atau webhook. Formulir kontak jarang butuh lebih dari satu kiriman dari satu orang dalam beberapa menit, jadi Strict, 2 per 10 menit per pengunjung, cocok setelah Anda live. [Dokumentasi spam](https://ikiform.com/id/docs/spam.md) membahas setiap lapisan, dan pemeriksaan manusia butuh satu panggilan tambahan di versi JavaScript: `challenge()` dari [SDK](https://ikiform.com/id/docs/sdk.md).

### Domain yang diizinkan dan deploy preview

**Allowed domains** mencocokkan sebuah domain beserta subdomainnya. Host statis memberi Anda lebih banyak domain dari yang Anda kira: domain production, subdomain milik host, dan alamat preview per branch atau pull request. Sebagian preview adalah subdomain dari alamat host situs Anda dan cocok dengannya; sebagian lain tidak dan perlu ditambahkan sendiri.

| Host | Tambahkan | Preview |
| --- | --- | --- |
| GitHub Pages | `username.github.io` dan domain kustom Anda | Tidak ada preview secara default |
| Vercel | `your-project.vercel.app` dan domain Anda | URL branch tidak cocok; tambahkan host persisnya selama pengujian |
| Netlify | `your-site.netlify.app` dan domain Anda | `deploy-preview-12--your-site.netlify.app` tidak cocok |
| Cloudflare Pages | `your-project.pages.dev` dan domain Anda | `abc123.your-project.pages.dev` sudah cocok |

Jangan pernah menambahkan host bersama begitu saja, seperti `github.io`, `vercel.app`, atau `netlify.app`. Setiap situs di host itu akan cocok, termasuk milik orang lain.

## Jika situs Anda mengirim Content-Security-Policy

Banyak template statis menyertakan `Content-Security-Policy` yang ketat. Dua direktif menentukan apakah formulir berfungsi: `form-action` untuk kiriman HTML biasa, dan `connect-src` untuk versi `fetch`. Jika salah satunya tidak ada, browser memblokir request sebelum keluar dari halaman, dan satu-satunya petunjuk hanya pesan di console.

`Header respons`:

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

Pertahankan `'self'` di `form-action`: beberapa browser juga memeriksa redirect kembali ke halaman terima kasih Anda terhadapnya. Letak header tergantung host: `_headers` di Netlify dan Cloudflare Pages, `vercel.json` di Vercel, tag `<meta>` di GitHub Pages, dan response headers policy CloudFront di depan S3. Halaman host di bawah menunjukkan masing-masing.

## Uji sebelum deploy

Buka halaman lewat server lokal, bukan dengan mengklik dua kali file-nya. Halaman yang dibuka sebagai `file://` mengirim `Origin: null`, yang tidak cocok dengan domain mana pun dan tidak dihitung sebagai uji lokal. Salah satu perintah ini menyajikan folder saat ini:

`Terminal`:

```bash
npx serve .

python3 -m http.server 8000
```

Tambahkan `localhost` ke **Allowed domains** selama pengujian. Kiriman dari `localhost` dihitung sebagai uji: workspace tanpa paket mendapat 50 per bulan, jadi Anda bisa membangun seluruh formulir sebelum membayar.

## Pilih situasi Anda

Formulir di atas berfungsi di semua host statis. Setiap host punya cara gagal sendiri, serta domain preview dan file header sendiri. Halaman-halaman ini membahasnya.

- [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.
- [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.
- [Cloudflare Pages](https://ikiform.com/id/guides/contact-form-static-site/cloudflare-pages.md): Tambahkan formulir kontak ke Cloudflare Pages tanpa menulis Function: kirim ke form backend, atur header di _headers, dan pakai key Turnstile Anda sendiri.
- [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.

## Pastikan berfungsi

Jalankan ini sekali di domain live Anda dan sekali di preview, jika host Anda menyediakannya. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat [harga](https://ikiform.com/id/pricing).

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Kirim dari server lokal Anda dan pastikan pesannya muncul di **Submissions**.
3. Deploy, lalu kirim dari situs live dengan JavaScript dimatikan. Anda seharusnya mendarat di halaman terima kasih Anda sendiri.
4. Nyalakan lagi JavaScript dan kirim dengan pesan kosong. Error seharusnya muncul di halaman, bukan sebagai halaman error hosted.
5. Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan **Reply** terkirim ke pengirim formulir jika formulir punya field `email`.
6. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
7. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| `405 Method Not Allowed` atau `404` tepat setelah menekan Kirim | Formulir mengirim ke situs Anda sendiri. Cek atribut `action` di HTML yang sudah di-deploy, bukan di source Anda; langkah build atau komponen `<Form>` dari framework mungkin telah menggantinya. |
| Tidak terjadi apa-apa, dan console menyebut `Content-Security-Policy` | Tambahkan `https://ikiform.com` ke `form-action` untuk kiriman biasa dan ke `connect-src` untuk `fetch`. |
| `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. |
| Uji lokal ditolak dengan `402 plan_required` | Halaman dibuka sebagai URL `file://`, jadi kiriman tidak punya origin yang bisa dipakai dan dihitung sebagai live. Sajikan folder dengan server lokal dan uji dari `localhost`. |
| Browser mendarat di halaman terima kasih Ikiform, bukan halaman Anda | `_redirect` diabaikan kecuali domainnya ada di **Allowed domains** dan berupa URL `https:`. Tambahkan domainnya, atau atur **Redirect after submit** di pengaturan formulir sebagai gantinya. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
| `429 rate_limited` | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai `Retry-After`, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |

## Pertanyaan

### Bisakah website statis punya formulir kontak yang berfungsi?

Bisa. Halamannya tetap statis; hanya action formulir yang mengarah ke layanan yang menerima kiriman. Form backend menyimpan setiap pesan, menyaring spam, dan mengirimi Anda email, jadi Anda tidak pernah menjalankan server.

### Apakah aman menaruh endpoint formulir di HTML publik?

Aman. ID formulir memang dimaksudkan publik, sama seperti alamat email di halaman kontak. Lindungi formulir dengan domain yang diizinkan, honeypot, rate limit, dan pemeriksaan manusia. Yang tidak boleh ada di situs statis adalah secret API key.

### Mengapa tidak memakai link mailto:?

Cara itu hanya berfungsi jika pengunjung sudah mengatur aplikasi email, tidak menyimpan apa pun, dan menaruh alamat Anda di halaman untuk dipanen scraper. Formulir menjaga pengunjung tetap di situs Anda dan memberi Anda catatan setiap pesan.

### Bisakah saya memakai mail() PHP di GitHub Pages atau Netlify?

Tidak. Host statis tidak menjalankan PHP atau kode server lain untuk halaman Anda. Skrip mail PHP butuh server, dan justru itulah yang dihindari situs statis.

### Bisakah formulir menerima upload file?

Belum. Kiriman yang berisi file ditolak dengan 422 files_not_supported. Minta link ke file sebagai gantinya, seperti URL shared drive.

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

- [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.
- [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.
- [Cloudflare Pages](https://ikiform.com/id/guides/contact-form-static-site/cloudflare-pages.md): Tambahkan formulir kontak ke Cloudflare Pages tanpa menulis Function: kirim ke form backend, atur header di _headers, dan pakai key Turnstile Anda sendiri.
- [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.
- [Kirim formulir ke email Anda](https://ikiform.com/id/guides/send-form-to-email.md): Terima setiap kiriman formulir di inbox tanpa mail server: mengapa mailto: dan mail() PHP gagal, dan setup yang menyaring spam serta memungkinkan Anda membalas.
- [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.
- [Melampaui formulir builder](https://ikiform.com/id/guides/website-builder-forms.md): Saat formulir Webflow, Netlify, Carrd, atau Framer mentok di batas atau kekurangan fitur, pertahankan formulir yang Anda desain dan arahkan ke backend formulir.
