Panduan13 menit baca
Kirim hasil formulir HTML ke email Anda
Terima setiap kiriman formulir di inbox tanpa mail server: mengapa mailto: dan mail() PHP gagal, dan setup yang menyaring spam serta memungkinkan Anda membalas.
Formulir HTML tidak bisa mengirim email sendiri, karena browser tidak punya cara untuk berbicara dengan mail server. Arahkan action formulir ke form backend, yang menerima kiriman, menyaring spam, menyimpannya, lalu mengirim email ke Anda dan tim. Beri nama field email email, dan menekan Reply di inbox akan langsung membalas ke orang yang mengisi formulir.
Kebanyakan situs hanya butuh satu hal dari formulir kontak: pesannya masuk ke inbox yang dibaca seseorang. Ternyata itu lebih sulit dari kelihatannya. HTML punya elemen <form> tetapi tidak punya cara mengirim email, dan jalan pintas lama entah melempar tugasnya kembali ke pengunjung atau berakhir di folder spam. Panduan ini menjelaskan alasannya, membandingkan pilihan yang ada secara jujur, dan menyiapkan formulir yang mengirimi Anda email di situs dan stack apa pun.
Mengapa formulir tidak bisa mengirim email sendiri
Saat pengunjung menekan Send, browser membuat satu request HTTP ke URL di action formulir. Hanya itu yang bisa dilakukan formulir. Mengirim email butuh percakapan SMTP dengan mail server, dari mesin yang dipercaya oleh server penerima. Browser tidak bisa membuka koneksi itu, dan seandainya bisa pun, email dari laptop pengunjung acak akan ditolak. Jadi selalu ada sesuatu di server di antara formulir dan inbox Anda: kode Anda sendiri, skrip di hosting Anda, atau sebuah layanan.
Dengan form backend, sesuatu itu adalah sebuah URL. Formulir mengirim ke https://ikiform.com/f/<id>, dan Ikiform mengerjakan sisanya:
- Pengunjung mengirim formulir Anda, lalu browser atau server Anda mengirimnya ke endpoint Ikiform Anda.
- Ikiform memeriksa field terhadap definisi formulir, memberi skor spam pada kiriman, lalu menyimpannya.
- Kiriman yang mencapai batas skor spam disimpan sebagai spam. Kiriman itu mendapat jawaban sukses yang sama, tetapi tidak memicu email maupun webhook.
- Setiap kiriman lainnya mengirim satu email ke setiap penerima, dengan alamat pengunjung sebagai reply-to.
- Email tiba di inbox Anda beberapa detik kemudian. Menekan Reply akan membalas ke pengunjung.
Enam cara memasukkan formulir ke inbox Anda
Masing-masing cocok untuk seseorang. Tabel ini membahas apa biayanya bagi Anda begitu pengunjung sungguhan, dan bot sungguhan, mulai memakai formulir.
| Pendekatan | Unggul dalam | Kekurangan |
|---|---|---|
action="mailto:you@example.com" | Tanpa server, tanpa akun, satu atribut. | Tidak ada yang terkirim. Browser membuka aplikasi email pengunjung dengan draf, atau tidak melakukan apa-apa jika tidak ada aplikasi email yang disiapkan. Alamat Anda tertera di halaman untuk para scraper. Lihat panduan Gmail. |
Skrip PHP dengan mail() | Berjalan di hampir semua shared hosting PHP, tanpa pihak ketiga. | Bergantung pada setup email hosting, dan email yang mengaku berasal dari domain Anda sering gagal SPF atau DMARC lalu masuk spam. Tidak ada penyaringan spam, tidak ada penyimpanan, dan sama sekali tidak ada PHP di hosting statis. Lihat tanpa PHP. |
| SDK email di browser, seperti EmailJS | Tanpa server. Template email yang Anda desain di dashboard mereka. | Key dan ID template ada di JavaScript halaman Anda, jadi siapa pun bisa mengirim template Anda dengan teks mereka sendiri. Lihat alternatif EmailJS. |
| Route Anda sendiri ditambah API email, seperti Resend, Postmark, Amazon SES, atau Nodemailer lewat SMTP | Kendali penuh: subjek Anda, HTML Anda, konfirmasi ke pengunjung. | Anda membangun sendiri validasi, penyaringan spam, rate limit, penyimpanan, dan percobaan ulang, memverifikasi domain pengirim, dan butuh server bahkan untuk situs statis. Lihat panduan Next.js. |
| Google Apps Script sebagai web app | Berjalan di akun Google yang sudah Anda punya. | Kuota skrip membatasi berapa banyak penerima yang bisa dikirimi email per hari, URL-nya tanpa perlindungan spam, dan Anda yang memelihara skripnya. |
| Form backend, seperti Ikiform, Formspree, atau Web3Forms | Satu URL di action. Penyaringan spam, penyimpanan, dan email tanpa server. | Tata letak dan subjek email ditentukan layanannya. Dengan Ikiform, tidak ada yang pernah dikirim lewat email ke pengunjung. |
Jika Anda butuh email dengan branding penuh, atau konfirmasi ke pengunjung, tulis route sendiri dengan API email. Jika Anda ingin pesan masuk ke inbox dan bisa dibalas, dengan spam tetap di luar, form backend adalah cara dengan kode paling sedikit. Sisa panduan ini memakai Ikiform.
Formulirnya
Tiga field sudah mencakup kebanyakan formulir kontak: siapa mereka, ke mana membalas, dan apa yang mereka inginkan. Pilih stack Anda di bawah; pilihan itu berlaku untuk setiap contoh kode di panduan ini.
Formulir ini berfungsi tanpa JavaScript sama sekali: browser mengirimnya, Ikiform mengirimi Anda email, dan pengunjung mendarat di halaman pada _redirect. Setiap field punya slot error kosong di bawahnya untuk skrip di bawah.
<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 maxlength="120" />
<p data-error="name"></p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p data-error="email"></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
<p data-error="message"></p>
<input type="hidden" name="page" value="/contact" />
<input type="hidden" name="_redirect" value="https://example.com/contact/sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<p id="contact-status" role="status"></p>
</form>Skrip ini membuat pengunjung tetap di halaman. Skrip mengirim dengan fetch, menulis setiap error dari server di bawah field-nya, lalu mengganti formulir dengan kalimat yang mengulang alamat yang akan Anda balas, sehingga salah ketik mudah terlihat.
const form = document.getElementById("contact");
const status = document.getElementById("contact-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
for (const slot of form.querySelectorAll("[data-error]")) slot.textContent = "";
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) {
const email = form.elements.namedItem("email").value;
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = `Sent. We'll reply to ${email}.`;
form.replaceWith(done);
return;
}
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
const slot = form.querySelector(`[data-error="${field}"]`);
if (slot) slot.textContent = messages[0];
}
status.textContent = result.error.fields ? "Check the fields above." : result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Server action mengirim pesan dengan secret key Anda. Action ini memetakan error.fields dari Ikiform menjadi satu pesan per field dan mengembalikan nilai yang sudah diketik, sehingga tidak ada tulisan pengunjung yang hilang saat terjadi error.
"use server";
import { headers } from "next/headers";
export interface ContactState {
sent: boolean;
email?: string;
errors: Record<string, string>;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, email: values.email, errors: {} };
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(result?.error?.fields ?? {})) {
errors[field] = (messages as string[])[0] ?? "Check this field.";
}
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your message. Try again.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { sent: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.sent) return <p role="status">Sent. We'll reply to {state.email}.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={state.values?.name} />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} defaultValue={state.values?.message} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
Ingin lebih jauh dengan Next.js? Baca Formulir kontak Next.js yang mengirim email.
Zod memeriksa setiap field di action sebelum apa pun dikirim, dan isInputError memberi Anda pesannya per field. Halaman harus dirender sesuai permintaan, jadi pertahankan prerender = false dan tambahkan adapter.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "Write at least a sentence."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, page: "/contact" }),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { email: input.email };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Sent. We'll reply to {result.data.email}.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{fields.message && <p role="alert">{fields.message[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
Server function membaca FormData di .validator(), mengirimnya dari server, dan mengembalikan alamat balasan atau satu error per field.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: true; email: string } | { sent: false; errors: Record<string, string> };
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...data, page: "/contact" }),
});
const result = await response.json();
if (result.ok) return { sent: true, email: data.email };
const errors: Record<string, string> = { form: String(result.error.message) };
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
errors[field] = String((messages as string[])[0]);
}
return { sent: false, errors };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
const errors = outcome && !outcome.sent ? outcome.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ sent: false, errors: { form: "Couldn't send your message. Try again." } });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">Sent. We'll reply to {outcome.email}.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.name && !errors.email && !errors.message ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
action route meneruskan pesan dan mengembalikan error field dari Ikiform lewat actionData, dengan kode status Ikiform dipertahankan oleh data().
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: String(formData.get("name") ?? "").trim(),
email,
message: String(formData.get("message") ?? "").trim(),
page: "/contact",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true as const, email };
const fields: Record<string, string[]> = result.error.fields ?? {};
return data(
{ sent: false as const, message: String(result.error.message), fields },
{ status: response.status },
);
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) return <p role="status">Sent. We'll reply to {actionData.email}.</p>;
const fields = actionData?.fields ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
Action default mengembalikan fail() dengan error field. update({ reset: false }) mempertahankan ketikan pengunjung saat server menjawab dengan error. Deklarasikan IKIFORM_SECRET_KEY di src/env.ts dengan defineEnvVars agar $app/env/private bisa membacanya.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(data.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { values, fields, message: String(result.error.message) });
}
return { sentTo: values.email };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const fields = $derived<Record<string, string[]>>(form?.fields ?? {});
</script>
{#if form?.sentTo}
<p role="status">Sent. We'll reply to {form.sentTo}.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{#if fields.name}<p role="alert">{fields.name[0]}</p>{/if}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if fields.email}<p role="alert">{fields.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{#if fields.message}<p role="alert">{fields.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !Object.keys(fields).length}<p role="alert">{form.message}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
{/if}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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
Server route menyimpan secret key dan meneruskan pesan Ikiform sebagai message milik createError dan error field di data-nya; $fetch mengembalikan keduanya di client sebagai data.message dan data.data.fields. Nuxt mengisi runtimeConfig.ikiformSecretKey dari NUXT_IKIFORM_SECRET_KEY.
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; email?: string; message?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
page: "/contact",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sentTo: body.email };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const values = reactive({ name: "", email: "", message: "", _gotcha: "" });
const fields = ref<Record<string, string[]>>({});
const error = ref("");
const sentTo = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
fields.value = {};
error.value = "";
try {
const result = await $fetch("/api/contact", { method: "POST", body: values });
sentTo.value = result.sentTo ?? values.email;
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
fields.value = data?.data?.fields ?? {};
error.value = data?.message ?? "Couldn't send your message. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="values.name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" v-model="values.email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="values.message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input v-model="values._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>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 Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server. Setiap contoh server meneruskan header Origin dari browser jika ada, karena fetch milik server sendiri tidak mengirimnya dan Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu.
Tanpa server sendiri, browser mengirim langsung ke Ikiform. Tidak ada key di kode ini; Allowed domains yang menentukan situs mana yang boleh mengirim.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setFields({});
setError("");
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;
}
setFields(result.error.fields ?? {});
setError(result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Sent. We'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" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}Kiriman sisi browser yang sama sebagai komponen Vue. Formulir tetap punya action, jadi masih terkirim jika skripnya gagal dimuat.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sentTo = ref("");
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
sending.value = true;
fields.value = {};
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
sentTo.value = String(body.get("email"));
return;
}
fields.value = result.error.fields ?? {};
error.value = result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Ganti
frm_xxxxxxxxxxxxxxxxdengan ID formulir Anda dari dashboard. name="email"menjadikan field itu alamat reply-to di email notifikasi. Pertahankan namanya, meskipun labelnya berbunyi "Email Anda".pageadalah field tersembunyi biasa. Field ini muncul di email, jadi Anda tahu dari halaman mana pesan itu berasal._gotchaadalah honeypot. Orang tidak pernah melihatnya; bot yang mengisinya disimpan sebagai spam dan tidak mengirim email ke siapa pun._redirecthanya berfungsi untuk URLhttps:di salah satu Allowed domains formulir. Hilangkan saja dan atur Redirect after submit di pengaturan formulir sebagai gantinya, atau Ikiform akan menampilkan halaman terima kasihnya sendiri.
Panduan framework membahas lebih jauh: Next.js, Astro, SvelteKit, Nuxt, React, Vue, dan HTML biasa. Pembuat situs seperti Webflow dan Carrd punya halaman sendiri, dan template formulir kontak menambahkan field topik.
Definisikan juga field-nya di dashboard, dan Ikiform menolak kiriman yang tidak cocok dengan 422 validation_failed dan pesan per field, yang ditampilkan kode di atas di samping setiap input. Coba di sini; ini menjalankan validator yang sama di browser Anda:
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
Seperti apa emailnya
Setiap kiriman yang diterima mengirim email sederhana yang mudah dibaca. Ini versi teks salah satunya:
From: Ikiform <notifications@ikiform.com>
To: you@yourcompany.com
Reply-To: ada@example.com
Subject: New submission: Contact
Contact got a new submission on Oct 25, 2026, 9:14 AM UTC.
name: Ada Lovelace
email: ada@example.com
message: Do you ship to Canada? I'd like to order 40 units before December.
page: /contact
View it in Ikiform:
https://ikiform.com/dashboard/submissions?form=frm_xxxxxxxxxxxxxxxx&submission=sub_...- Email datang dari
notifications@ikiform.com. Anda tidak perlu menyiapkan record DNS atau domain pengirim, karena email dikirim dari domain Ikiform. - Subjeknya selalu
New submission: <form name>._subjecttidak mengubah subjek email notifikasi. Nilainya diteruskan ke webhook dan API sebagaimeta.subject. Untuk membedakan formulir, beri setiap formulir nama yang jelas di dashboard, seperti "Kontak, halaman harga". - Field muncul sesuai urutan kirimnya, diberi label berdasarkan
name-nya. Field yang diawali_tidak ditampilkan. - Formulir atau jawaban yang sangat panjang dipendekkan, dan email menyebutkan berapa field lagi yang ada di dashboard. Halaman batas berisi angkanya.
- Field yang terlihat seperti password atau nomor kartu tidak ditampilkan, dan email untuk kiriman yang memuatnya ditahan untuk ditinjau. Jangan kumpulkan kredensial dengan form backend.
- Anggota workspace mendapat tombol View submission. Orang di luar workspace mendapat field-nya dan link berhenti berlangganan.
Siapa yang menerima email
Penerima diatur per formulir di dashboard, tidak pernah oleh formulir itu sendiri. Field yang menentukan penerima akan membuat siapa pun bisa memakai formulir Anda untuk mengirim email ke siapa saja, jadi Ikiform tidak menyediakannya.
| Siapa | Cara menambahkannya | Catatan |
|---|---|---|
| Default | Tidak perlu apa-apa | Anggota yang dipilih sebagai Notification email workspace, atau setiap owner workspace jika belum ada yang dipilih |
| Anggota pilihan | Centang mereka di pengaturan Notifications formulir | Hanya anggota dengan alamat email terverifikasi |
| Orang di luar workspace | Add someone outside the workspace, lalu Send link | Mereka baru menerima email setelah mengonfirmasi. Maksimal 5 per formulir, dan masing-masing bisa berhenti berlangganan |
Setiap formulir mengirim email ke maksimal 10 alamat. Setiap penerima mendapat salinannya sendiri, bukan CC bersama, jadi tidak ada yang melihat alamat penerima lain. Jika mailbox penerima memantulkan email (bounce), Ikiform menjeda email ke alamat itu untuk sementara dan dashboard menampilkan Emails bounced di sampingnya. Beberapa penerima membahas perutean berdasarkan topik, yang tidak bisa dilakukan hanya dengan daftar penerima.
Balas ke orangnya, bukan ke Ikiform
Ikiform menentukan alamat reply-to dari field _replyto terlebih dulu, lalu dari field bernama email, email_address, your_email, reply_to, contact_email. Nilainya harus satu alamat email yang valid, atau akan diabaikan. Dengan reply-to terisi, menekan Reply di Gmail, Outlook, atau Apple Mail membuka balasan ke pengunjung, dan jawaban Anda dikirim dari alamat Anda sendiri.
Tanpa reply-to, balasan terkirim ke notifications@ikiform.com, yang tidak dibaca siapa pun. Itulah kejutan paling umum, dan biasanya penyebabnya field bernama user_email atau Email Address dengan spasi. Ganti namanya, atau tambahkan _replyto. Panduan reply-to membahas formulir dengan dua field email dan pembuat situs yang mengganti nama field untuk Anda.
Saat banyak kiriman datang sekaligus
Setiap formulir mengirim hingga 50 email notifikasi per jam, atau 10 selama workspace berumur kurang dari 7 hari. Lewat dari itu, sisa kiriman di jam tersebut tetap disimpan seperti biasa dan datang sebagai satu email ringkasan setelah jam itu berakhir. Batas ini melindungi inbox Anda, dan reputasi email Ikiform di mata penyedia email Anda, saat sebuah formulir dibagikan di tempat besar atau ada bot yang lolos dari filter.
- 50 kiriman pertama ke sebuah formulir dalam satu jam masing-masing mengirim email seperti biasa.
- Kiriman berikutnya di jam yang sama disimpan dan langsung tampil di dashboard, tanpa email untuk masing-masing.
- Setelah jam itu berakhir, setiap penerima mendapat satu ringkasan yang menghitungnya dan menautkan ke dashboard.
Subjek ringkasan berisi jumlah dan nama formulir, tanpa daftar field dan tanpa reply-to, jadi jawab pesan-pesan itu dari dashboard. Jika Anda sering melewati batas, email bukan saluran yang tepat untuk formulir itu: kirim ke Slack atau inbox bersama lewat webhook sebagai gantinya.
Jauhkan spam dari inbox Anda
Email membuat spam jadi mahal: setiap kiriman bot adalah notifikasi yang dibuka seseorang. Ikiform menyimpan spam dengan jawaban sukses biasa, jadi bot tidak belajar apa-apa, dan tidak mengirim email untuknya. Empat hal menentukan apa yang dianggap spam:
| Pertahanan | Yang ditangkap | Setup |
|---|---|---|
| Honeypot | Bot yang mengisi setiap input | Input tersembunyi _gotcha |
| Skor spam | Link di field nama, tiga link atau lebih, frasa spam yang dikenal, alamat sekali pakai, dan kiriman identik berulang. Pada skor 5, kiriman dianggap spam | Tidak ada |
| Domain yang diizinkan | Kiriman dari situs lain | Tambahkan domain Anda di pengaturan formulir |
| Pemeriksaan manusia | Bot yang menjalankan browser sungguhan | Atur Spam check ke Ikiform |
Sesekali cek filter Spam di dashboard. Jika ada pesan asli yang masuk ke sana, tandai sebagai bukan spam dan emailnya akan dikirim saat itu. Dokumentasi spam menjelaskan setiap sinyal, termasuk kapan sebaiknya menyalakan pemeriksaan manusia.
Yang tidak bisa dilakukan email
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. Jika situs Anda menjanjikan "pesan Anda sudah kami terima", tampilkan itu di halaman setelah formulir dikirim, seperti yang sudah dilakukan kode di atas.
- Inbox tim bersama. Tambahkan alamat tim sebagai penerima, atau teruskan dari penyedia email Anda.
- Kanal chat. Kirim setiap kiriman ke Slack, Discord, atau Microsoft Teams lewat webhook. Chat cocok untuk formulir yang menerima lebih dari beberapa pesan per hari.
- Spreadsheet atau CRM. Google Sheets, atau alat apa pun lewat Zapier atau n8n.
- Lampiran. Unggahan file belum didukung. Kiriman berisi file mendapat
422 files_not_supported. Minta link ke file yang dibagikan sebagai gantinya.
Mematikan email
Beberapa formulir lebih baik tanpa email, seperti pendaftaran newsletter yang ramai. Matikan Email new submissions di pengaturan Notifications formulir. Kiriman tetap disimpan, dan webhook tetap terkirim.
Pilih situasi Anda
Setup di atas sama di mana saja. Halaman-halaman ini membahas inbox tertentu, stack tertentu, atau alat yang sedang Anda tinggalkan.
- Tanpa PHPGanti skrip contact.php yang memanggil mail() dengan formulir yang mengirim ke endpoint hosted, dengan nama field, halaman terima kasih, dan balasan tetap sama.
- Ke GmailTerima kiriman formulir di Gmail tanpa link mailto:, app password, atau Apps Script, lalu beri label, jauhkan dari spam, dan balas dari Gmail.
- Beberapa penerimaKirim email dari satu formulir ke seluruh tim, atau arahkan setiap pesan ke orang yang tepat berdasarkan topik, tanpa membiarkan formulir sendiri memilih siapa yang menerima email.
- Balas dari inbox AndaBuat Reply di notifikasi formulir membalas ke orang yang mengisi formulir: nama field yang berfungsi, kapan memakai _replyto, dan mengapa balasan bisa nyasar.
- Alternatif EmailJSPindahkan formulir kontak dari EmailJS: mengapa key email di sisi browser berisiko, apa yang berubah di kode Anda, dan apa yang Anda lepaskan, seperti balasan otomatis.
- Next.jsDua cara mengirim email dari formulir Next.js dengan Server Action: kirim sendiri dengan Resend atau Nodemailer, atau teruskan ke form backend, dibandingkan secara jujur.
Pastikan berfungsi
Jalankan langkah-langkah ini sebelum formulir live. 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. - Di pengaturan Notifications formulir, pastikan Email new submissions menyala dan orang yang tepat sudah dicentang.
- Isi formulir di halaman Anda lalu kirim. Anda mendarat di halaman terima kasih Anda, atau halaman hosted milik Ikiform.
- Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- Buka email notifikasi. Subjeknya
New submission:diikuti nama formulir, dan setiap field yang Anda kirim tercantum. - Tekan Reply. Baris To menampilkan alamat yang Anda ketik di formulir, bukan alamat Ikiform.
- 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 |
|---|---|
| Kiriman ada di dashboard tetapi tidak ada email yang datang | Cek dulu folder spam atau promosi, lalu tambahkan notifications@ikiform.com ke kontak Anda. Setelah itu cek pengaturan Notifications formulir: email mungkin mati, alamat luar mungkin masih berstatus Waiting for confirmation, atau menampilkan Emails bounced. |
| 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. |
| Reply terkirim ke notifications@ikiform.com | Tidak ditemukan reply-to. Beri nama field email salah satu dari email, email_address, your_email, reply_to, contact_email, atau tambahkan field _replyto berisi alamat pengunjung. |
| Email berhenti dan yang datang malah ringkasan | Formulir melewati 50 email dalam satu jam (10 di workspace baru). Semuanya ada di dashboard. Cari lonjakan bot di Spam, atau pakai webhook untuk formulir yang ramai. |
_subject tidak mengubah subjek | _subject tidak mengubah subjek email notifikasi. Nilainya diteruskan ke webhook dan API sebagai meta.subject. |
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. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
402 test_limit_reached | Workspace belum punya paket dan sudah memakai 50 uji gratis dari localhost bulan ini. Pilih paket, atau pakai Send test di dashboard. |
Pertanyaan
Tidak dengan sendirinya. Formulir hanya membuat request HTTP, sedangkan mengirim email butuh server yang berbicara dengan mail server. Form backend adalah server itu: arahkan action formulir ke sana dan ia mengirimi Anda email, sehingga situs Anda sendiri tetap bisa statis.
Action mailto: tidak mengirim apa pun. Ia meminta komputer pengunjung membuka aplikasi email dengan draf, yang gagal bagi siapa pun yang belum menyiapkannya, dan pengunjung tetap harus menekan Send. Banyak orang menyerah di situ, dan Anda tidak pernah tahu mereka sempat mencoba.
Tidak. Subjeknya selalu "New submission:" diikuti nama formulir, jadi beri setiap formulir nama yang jelas. Field _subject disimpan dan diteruskan ke webhook dan API, tetapi tidak mengubah email.
Hingga 10 per formulir: anggota workspace yang Anda centang di pengaturan formulir, ditambah hingga 5 alamat luar yang mengonfirmasi lewat link. Masing-masing mendapat salinannya sendiri.
Tidak. Ikiform mengirim email ke Anda dan tim, bukan ke pengirim formulir, jadi tidak ada autoresponder atau email konfirmasi. Kirim webhook ke alat email Anda dan kirim konfirmasinya dari sana.
Email dikirim dari domain Ikiform sendiri, jadi Anda tidak butuh record SPF atau DKIM untuk domain Anda. Email pertama tetap bisa masuk spam atau promosi; tambahkan notifications@ikiform.com ke kontak Anda atau buat filter agar tidak pernah terjadi.
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.