Panduan13 menit baca
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.
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.
- Tanpa action, atau dengan action yang mengarah ke path di situs Anda sendiri, browser mengirim ke host statis.
- Host tidak punya kode untuk dijalankan saat menerima POST, jadi ia menjawab 405 atau 404 dan pesannya hilang.
- Dengan action yang mengarah ke endpoint Ikiform Anda, kiriman masuk ke server yang menerimanya.
- Ikiform memeriksa spam pada pesan, menyimpannya, dan mengirimi Anda email.
- Ikiform menjawab dengan redirect 303, dan browser memuat halaman terima kasih di situs Anda sendiri.
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.
- Pengunjung mengisi formulir kontak, lalu formulir mengirimnya ke endpoint Ikiform Anda.
- Ikiform memeriksa honeypot, rate limit, dan skor spam, lalu menyimpan pesannya.
- Setiap penerima notifikasi mendapat email, dengan reply-to diatur ke alamat pengirim.
- Pesan muncul di dashboard, tempat Anda bisa mencarinya, memulihkan spam, dan mengekspor CSV.
- Jika Anda menambahkan webhook, Ikiform mengirim salinan bertanda tangan ke Slack, Discord, alat otomasi, atau kode Anda sendiri.
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 | 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, Tally, atau 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, Basin, atau 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 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, Hugo, Eleventy, dan Gatsby.
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.
<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.
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;
});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.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"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 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.
---
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.
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.
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(),
],
});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>
);
}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.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;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>
);
}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.
export const prerender = true;<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 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.
<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>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.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { 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>
);
}Kiriman browser yang sama dalam single-file component Vue, membaca endpoint dari variabel VITE_ yang sama. Hanya error pertama yang ditampilkan, di samping tombol.
<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_xxxxxxxxxxxxxxxxdengan 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
_gotchaadalah honeypot. Orang tidak pernah melihatnya; bot yang mengisi setiap input menandai dirinya sendiri sebagai spam. - Versi JavaScript mengirim
Accept: application/json, sehingga Ikiform menjawab201saat berhasil, atau422dengan objekerror.fieldsyang 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, SvelteKit, dan Nuxt menunjukkan versi server-nya, dan Dari server membahas secret key.
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.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
| 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.
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.
Ingin pesan masuk ke chat tim, sebagai pengganti atau tambahan email? Tambahkan webhook dan ikuti panduan Slack, Discord, atau Telegram.
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.
- Browser mengirim field formulir ke endpoint Ikiform Anda.
- Ikiform menyimpan pesan dan menjawab 303 See Other dengan header Location.
- Browser mengikuti redirect dengan GET ke halaman terima kasih Anda, yang disajikan host statis seperti file lainnya.
Ikiform memilih tujuan dengan urutan ini:
- Redirect after submit di pengaturan formulir. URL
http:atauhttps:apa pun bisa dipakai, karena hanya tim Anda yang bisa mengaturnya. Ini pilihan paling sederhana untuk satu situs. - Field
_redirectdi formulir, jika berupa URLhttps: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.
<!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 membahas setiap lapisan, dan pemeriksaan manusia butuh satu panggilan tambahan di versi JavaScript: challenge() dari SDK.
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.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comPertahankan '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:
npx serve .
python3 -m http.server 8000Tambahkan 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 PagesGitHub Pages tidak bisa menjalankan kode server, jadi POST formulir mendapat 405 Not Allowed; kirim ke form backend dan atur redirect project site dengan benar.
- VercelHalaman statis di Vercel menjawab POST formulir dengan 405 Method Not Allowed; ini penyebabnya, dan cara mengirim formulir ke backend.
- NetlifyJalankan formulir kontak di situs Netlify tanpa Netlify Forms: tanpa deteksi saat build, kiriman JSON dari React, dan formulir terpisah untuk deploy preview.
- Cloudflare PagesTambahkan formulir kontak ke Cloudflare Pages tanpa menulis Function: kirim ke form backend, atur header di _headers, dan pakai key Turnstile Anda sendiri.
- Amazon S3Endpoint 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 portofolioFormulir 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.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Kirim dari server lokal Anda dan pastikan pesannya muncul di Submissions.
- Deploy, lalu kirim dari situs live dengan JavaScript dimatikan. Anda seharusnya mendarat di halaman terima kasih Anda sendiri.
- Nyalakan lagi JavaScript dan kirim dengan pesan kosong. Error seharusnya muncul di halaman, bukan sebagai halaman error hosted.
- Cek inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field, dan Reply terkirim ke pengirim formulir jika formulir punya field
email. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - 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
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.
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.
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.
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.
Belum. Kiriman yang berisi file ditolak dengan 422 files_not_supported. Minta link ke file sebagai gantinya, seperti URL shared drive.
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.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.