Panduan16 menit baca
Cara membuat daftar tunggu untuk SaaS baru
Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
Pasang formulir HTML biasa di landing page Anda yang mengirim email pengunjung ke form backend, jadi Anda tidak perlu menjalankan server sebelum peluncuran. Teruskan setiap pendaftaran lewat webhook ke tool email yang akan Anda pakai saat peluncuran, dan biarkan tool itu yang mengirim konfirmasi. Di hari peluncuran, ekspor daftarnya sebagai CSV, hapus duplikat berdasarkan email, lalu kirim satu email peluncuran.
Daftar tunggu adalah cara termurah untuk menguji ide SaaS. Jika orang asing mau meninggalkan email untuk sesuatu yang belum ada, Anda punya alasan untuk membangunnya, sekaligus daftar orang yang perlu dikabari saat produknya rilis. Panduan ini membangun daftar tunggu di landing page Anda sendiri, tanpa database atau server, dan menyiapkannya untuk hari peluncuran.
Cara kerja daftar tunggu
Setiap daftar tunggu punya tiga bagian yang sama: formulir di halaman Anda, tempat yang menyimpan setiap pendaftaran, dan tool yang Anda pakai untuk mengirim email ke daftar itu. Di sini, formulir mengirim ke Ikiform. Ikiform memeriksa kiriman dari spam, menyimpannya, mengirimi Anda email, dan mengirim webhook bertanda tangan. Relay kecil atau tool otomatisasi lalu meneruskan alamatnya ke tool email Anda.
- Pengunjung memasukkan emailnya di landing page Anda, dan formulir mengirimnya ke endpoint Ikiform Anda.
- Ikiform memeriksa honeypot, skor spam, dan rate limit, lalu menyimpan pendaftarannya.
- Pendaftaran muncul di dashboard, tempat Anda bisa mencarinya dan mengekspor daftarnya sebagai CSV.
- Ikiform mengirim email ke Anda dan tim Anda. Ikiform tidak pernah mengirim email ke orang yang mendaftar.
- Ikiform mengirim webhook submission.created bertanda tangan.
- Relay atau tool otomatisasi menambahkan alamatnya ke tool email Anda, yang mengirim konfirmasi dan, nanti, email peluncuran.
Empat cara menjalankan daftar tunggu
Tidak ada satu tool yang paling benar. Tabel ini membandingkan empat setup umum berdasarkan keunggulannya dan biaya yang harus Anda tanggung nanti.
| Pendekatan | Unggul di | Kompromi |
|---|---|---|
| SaaS daftar tunggu, seperti LaunchList atau Waitlister | Link referral, leaderboard, dan posisi antrean, langsung siap pakai. Halaman hosted jika Anda belum punya situs. | Daftar Anda tinggal di produk lain. Widget-nya terlihat seperti milik mereka kecuali Anda membayar atau mengubah gayanya. Harganya biasanya naik seiring jumlah pendaftar. |
| Embed form builder, seperti Typeform atau Tally | Alur satu pertanyaan yang rapi, tanpa kode sama sekali. | Iframe yang tidak serasi dengan halaman Anda dan memuat skripnya sendiri. Branding, batas respons, dan webhook bergantung pada paketnya. |
| Database dan API route Anda sendiri | Kendali penuh atas data dan logika apa pun, seperti kode referral. | Anda membangun sendiri perlindungan spam, rate limit, notifikasi, dan ekspor, dan Anda butuh server meskipun landing page-nya statis. |
| Form backend, seperti Ikiform | HTML Anda sendiri di situs apa pun. Penyaringan spam, penyimpanan, notifikasi email, webhook bertanda tangan, dan ekspor CSV tanpa server. | Tanpa leaderboard referral dan tanpa email ke orang yang mendaftar. Keduanya datang dari tool email Anda. |
Pilih SaaS daftar tunggu jika mekanisme referral adalah inti rencana Anda. Pilih form backend jika Anda ingin formulirnya serasi dengan bagian lain situs Anda, daftarnya tetap mudah dipindahkan, dan email dikirim dari tool yang memang akan Anda pakai setelah peluncuran. Sisa panduan ini memakai form backend.
Formulirnya
Untuk awal, minta email saja. Setiap field tambahan mengurangi pendaftaran, dan Anda bisa bertanya lagi nanti, lewat email, kepada orang yang sudah bilang ya. Pilih stack Anda di bawah; pilihan ini berlaku untuk setiap contoh kode di panduan-panduan ini.
Tempel formulir di tempat pendaftaran harus muncul. Formulir mengirim dengan cara biasa lalu redirect, jadi tetap berfungsi tanpa JavaScript sama sekali.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Join the waitlist</button>
</form>Agar pengunjung tetap di halaman, tambahkan skrip ini setelah formulir. Skrip mengirim dengan fetch, mengganti formulir dengan kalimat terima kasih, dan menampilkan error field dari server.
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);
const labels = {
email: "Email",
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
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.replaceWith("You're on the list. We'll email you at launch.");
return;
}
const fields = Object.entries(result.error.fields ?? {});
status.textContent = fields.length
? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
: result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});Ingin lebih jauh dengan HTML? Baca Cara menambahkan formulir daftar tunggu dengan HTML biasa ke landing page apa pun.
Server action meneruskan pendaftaran dengan secret key Anda, dan useActionState menampilkan hasilnya tanpa memuat ulang halaman. Formulir tetap terkirim meskipun JavaScript belum dimuat.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", email };
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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Ingin lebih jauh dengan Next.js? Baca Cara membuat daftar tunggu Next.js dengan server action.
Astro Action dengan accept: "form" memvalidasi email dengan Zod lalu meneruskannya. Formulir terkirim tanpa JavaScript di sisi klien, jadi halamannya harus dirender on demand: tambahkan adapter dan pertahankan prerender = false.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_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),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}Ingin lebih jauh dengan Astro? Baca Membuat daftar tunggu di Astro.
Fungsi POST createServerFn menerima FormData, memeriksanya di .validator(), lalu meneruskannya dari server. Route memanggilnya lewat useServerFn.
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";
const joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
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),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Ingin lebih jauh dengan TanStack Start? Baca Membuat daftar tunggu di TanStack Start.
action milik route meneruskan kiriman, dan actionData membawa hasilnya kembali ke komponen. data() mempertahankan kode status Ikiform saat terjadi error.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}Ingin lebih jauh dengan React Router? Baca Membuat daftar tunggu di React Router.
Form action default meneruskan kiriman dan mengembalikan fail() beserta pesannya saat terjadi error. use:enhance mengirim tanpa memuat ulang dan mempertahankan email yang sudah diketik di input. Di SvelteKit 3, 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 email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
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({
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}Ingin lebih jauh dengan SvelteKit? Baca Membuat daftar tunggu di SvelteKit.
Server route menjaga secret key tetap di luar browser. Atur NUXT_IKIFORM_SECRET_KEY di environment, dan Nuxt mengisi runtimeConfig.ikiformSecretKey darinya.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: 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({
email: body.email?.trim() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>Ingin lebih jauh dengan Nuxt? Baca Membuat daftar tunggu di Nuxt.
Single-page app tidak punya server sendiri, jadi formulir mengirim langsung ke Ikiform dari browser. Jangan pernah menaruh secret key di kode ini; Allowed domains (domain yang diizinkan) yang melindungi formulirnya.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Kiriman sisi browser yang sama dalam single-file component Vue. Formulir tetap punya action, jadi masih berfungsi jika skripnya gagal dimuat.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- Endpoint adalah URL formulir Anda. Ganti
frm_xxxxxxxxxxxxxxxxdengan ID dari dashboard. name="email"adalah ID field. Biarkan tetapemail: Ikiform memakainya sebagai alamat reply-to di email notifikasi, dan mencocokkannya dengan daftar domain email sekali pakai.- Input tersembunyi
_gotchaadalah honeypot. Orang tidak pernah melihatnya. Bot yang mengisi setiap input menandai dirinya sendiri sebagai spam, dan versi server meneruskannya tanpa diubah. - Input tersembunyi
refmencatat dari mana pendaftaran berasal. Ubah nilainya per halaman atau kampanye. - Jika ada definisi field di tab Fields, input yang tidak tercantum di sana ditolak dengan
422dan errorunknown_field, jadi cantumkanrefselainemail, seperti di bawah. - Kiriman HTML biasa me-redirect browser setelah berhasil. Atur Redirect after submit di pengaturan formulir ke halaman terima kasih Anda sendiri, atau Ikiform akan menampilkan halaman hosted miliknya.
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}IKIFORM_SECRET_KEY di environment variable khusus server.Versi server meneruskan header Origin dari browser, karena Ikiform mengenali tes localhost gratis dari header itu, sedangkan fetch milik server tidak mengirimnya.
Panduan framework di dokumentasi membahas lebih dalam: Next.js, Astro, SvelteKit, Nuxt, React, Vue, dan HTML biasa. SDK membungkus request browser sebagai submit(), dan template formulir daftar tunggu menghasilkan versi empat field.
Field yang layak ditanyakan
Email adalah satu-satunya field yang Anda butuhkan. Jika ingin memilah daftar sebelum peluncuran, tambahkan paling banyak dua lagi. Coba versi empat field di bawah: versi ini menjalankan validator Ikiform di browser Anda dan menunjukkan apa yang akan disimpan, atau 422 yang akan Anda terima.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
ref, yang diisi halaman Anda lewat input tersembunyi, bukan kotak yang terlihat. Tempel di tab Fields formulir, dan Ikiform menerapkannya di server.| Field | Alasan menanyakannya | Perlu diingat |
|---|---|---|
email | Daftarnya itu sendiri | Wajib. Gunakan type="email" agar ponsel menampilkan keyboard yang tepat |
name | Email peluncuran yang personal | Opsional. Nama depan sudah cukup |
role | Undang founder lebih dulu, atau tulis email yang berbeda untuk engineer | select menjaga jawaban tetap rapi untuk difilter |
source | Ketahui channel mana yang berhasil sebelum Anda mengeluarkan uang untuknya | Teks bebas memang jujur tapi berantakan. Input tersembunyi ref lebih rapi, lihat di bawah |
ref | Hitung pendaftaran per halaman atau kampanye tanpa bertanya | Input tersembunyi. text opsional dengan max_length 40 |
Untuk melacak asal pendaftaran tanpa bertanya, tambahkan input tersembunyi per halaman atau per kampanye. Nama yang diawali _ adalah instruksi untuk Ikiform dan tidak pernah disimpan, jadi pakai nama biasa seperti ref:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Setiap kiriman juga mencatat origin dan referrer halamannya, jadi Anda bisa melihat dari halaman mana sebuah pendaftaran datang meski tanpa ref. Input _redirect mengarahkan browser ke halaman terima kasih Anda sendiri; ini hanya berfungsi untuk URL https: di salah satu Allowed domains formulir.
true bersama timestamp kiriman, yang mencatat kapan orang itu setuju. Template newsletter punya checkbox ini.Jauhkan bot dari daftar
Formulir publik dengan field email akan didatangi bot dalam hitungan hari. Pendaftaran palsu menggelembungkan angka Anda dan, lebih buruk lagi, email peluncuran Anda mendarat di spam trap yang merusak reputasi pengirim Anda. Ikiform memasang empat lapis pertahanan; dua yang pertama aktif tanpa setup.
| Pertahanan | Yang dihentikan | Setup |
|---|---|---|
| Honeypot | Bot sederhana yang mengisi setiap input | Input tersembunyi _gotcha di formulir di atas |
| Penilaian spam | Alamat sekali pakai (+2), pengulangan identik (+3), link, dan frasa spam yang dikenal. Pada skor 5, pendaftaran disimpan sebagai spam | Tidak ada |
| Rate limit | Skrip yang mengirim berulang-ulang | Aktif secara default: Standard, 5 per menit per pengunjung, dan 300 per menit untuk seluruh formulir |
| Pemeriksaan manusia | Bot yang menjalankan browser sungguhan | Atur Spam check ke Ikiform. Ini menjalankan Cloudflare Turnstile tanpa key yang perlu dikelola |
Spam disimpan dengan jawaban sukses biasa, jadi bot tidak belajar apa-apa. Spam tidak memicu email atau webhook dan tidak dihitung dalam pemakaian Anda. Periksa filter Spam di dashboard seminggu sekali di bulan pertama; jika pendaftaran asli masuk ke sana, tandai sebagai bukan spam, dan webhook-nya terkirim saat itu.
Tambahkan juga domain Anda di Allowed domains. Kiriman dari situs lain lalu ditandai sebagai spam, atau ditolak dengan 403 origin_not_allowed jika Anda memilih Reject. Dengan pemeriksaan manusia aktif, formulir HTML tidak perlu diubah. Versi fetch harus meminta token lebih dulu dengan challenge() dari @ikiform/sdk; dokumentasi spam menunjukkan keempat barisnya.
Orang yang sama, dua kali
Orang mendaftar lagi dari tab lain, perangkat kedua, atau seminggu kemudian karena lupa. Ikiform menyimpan setiap kiriman yang diterima dan tidak menggabungkan atau menolak pengulangan berdasarkan email. Inilah yang terjadi pada pengulangan:
| Kasus | Hasil |
|---|---|
| Email sama, field berbeda, atau lebih dari 24 jam kemudian | Kiriman kedua yang normal |
| Field yang sama lagi dalam 24 jam | +3 ke skor spam, di bawah 5, jadi tetap kiriman kedua yang normal |
| Pengulangan identik dari domain email sekali pakai | +3 dan +2, mencapai 5: disimpan sebagai spam |
Jadi, bersiaplah menemukan beberapa duplikat, dan hapus di tempat daftar itu akhirnya berada. Ada tiga tempat yang bisa dipakai, dari yang paling ringan hingga paling repot:
- Di tool email. Kebanyakan tool email mengenali kontak dari alamatnya, jadi menambahkan alamat yang sama dua kali hanya memperbarui satu kontak, bukan membuat dua.
- Saat ekspor. Hapus email duplikat dari CSV sebelum Anda mengimpornya ke mana pun. Ada dua cara di bawah.
- Di relay Anda. Ubah alamat menjadi huruf kecil dan hapus spasinya, lalu masukkan dengan unique constraint atau upsert, sehingga pengulangan tidak berefek apa-apa.
Ingin mencegah klik ganda membuat dua baris sama sekali? Kirim idempotency key per pemuatan halaman dengan versi fetch. Ini mencegah pengulangan tak sengaja; ini tidak mencegah orang yang sengaja mendaftar dua kali.
Mengonfirmasi pendaftaran
Ikiform tidak mengirim email ke orang yang mengisi formulir. Email notifikasi hanya dikirim ke Anda dan tim Anda, jadi tidak ada autoresponder, email konfirmasi, atau double opt-in. Kirim semua itu dari tool email Anda, dipicu oleh webhook. Ini disengaja: tool yang mengirim email peluncuran sebaiknya juga mengirim konfirmasi, agar unsubscribe dan bounce tercatat di satu tempat.
Dua cara menghubungkan Ikiform ke tool itu:
- Tanpa kode. Tambahkan endpoint webhook yang mengarah ke tool otomatisasi, lalu gunakan action-nya untuk tool email Anda. Lihat Zapier, Make, atau n8n.
- Relay kecil. Route handler di host Node mana pun memverifikasi tanda tangan dan memanggil API tool email Anda. Contoh di bawah adalah route Next.js; kode yang sama berjalan di Cloudflare Worker.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const email = String(event.data.fields.email ?? "").trim().toLowerCase();
if (!email) return new Response(null, { status: 204 });
const response = await fetch(process.env.LIST_API_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LIST_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
email,
name: event.data.fields.name ?? null,
tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
}),
});
if (response.status === 409) return new Response(null, { status: 204 });
if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookmemeriksa tanda tangan dan timestamp, jadi tidak ada orang lain yang bisa menambahkan alamat lewat URL ini.- Ganti
fetchdengan panggilan add-contact dari tool email Anda. Kebanyakan menerima email dan list atau tag, lalu mengirim email konfirmasinya sendiri jika double opt-in aktif di tool tersebut. - Menjawab
204memberi tahu Ikiform bahwa pengiriman berhasil.502membuat Ikiform mencoba ulang, denganwebhook-idyang sama, jadi gangguan singkat di tool email Anda tidak menghilangkan pendaftaran.
Tambahkan endpoint di Webhooks pada dashboard dan salin secret whsec_ miliknya ke IKIFORM_WEBHOOK_SECRET. Satu workspace bisa punya 3 endpoint. Dokumentasi webhook membahas percobaan ulang dan payload, dan server Anda sendiri punya versi yang menyimpan setiap pengiriman sekali saja.
Hari peluncuran
- Halaman tayang dengan formulir yang mengirim ke Ikiform.
- Setiap minggu, bagikan halamannya dan baca pendaftaran baru beserta jawabannya.
- Seminggu sebelum peluncuran, ekspor daftar sebagai CSV, hapus duplikat, dan bagi berdasarkan peran.
- Di hari peluncuran, kirim satu email ke daftar dari tool email Anda.
Ekspor daftar
Buka Submissions di dashboard, pilih formulir daftar tunggu, atur status ke Received dan rentang tanggal ke All time, lalu pilih Export CSV. Kolomnya adalah id, created_at, form, dan status, lalu satu kolom per field. File-nya UTF-8 dengan byte order mark, jadi aksen tetap utuh di Excel. Satu ekspor memuat hingga 100.000 baris; lebih dari itu, telusuri halaman demi halaman lewat REST API.
Untuk menghapus duplikat di Google Sheets, impor CSV-nya dan gunakan rumus ini di sheet kosong, dengan email di kolom E:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)Rumus ini menyisakan satu baris per alamat, mengabaikan huruf besar-kecil dan spasi, diurutkan berdasarkan email. Atau jalankan ini dengan Node di folder yang berisi hasil ekspor. Skrip ini menyimpan baris pertama untuk setiap alamat dan mengasumsikan tidak ada koma di dalam nilai, yang berlaku untuk formulir yang hanya berisi email:
import { readFileSync, writeFileSync } from "node:fs";
const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
const email = row.split(",")[emailAt]?.trim().toLowerCase();
if (!email || seen.has(email)) return false;
seen.add(email);
return true;
});
writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);Kirim email peluncuran
Kirim dari tool email Anda, bukan dari inbox pribadi. Inbox pribadi yang mengirim ke ratusan alamat sekaligus terlihat seperti spam bagi penyedia email, dan Anda akan butuh penanganan unsubscribe begitu email terkirim. Buat singkat:
Subject: Acme is live. You're in.
Hi Ada,
You joined the Acme waitlist in the spring. Today it's open.
Sign in here: https://acme.example/start
You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.
Ada, founder of Acme- Sebutkan apa yang sudah tayang dan beri link langsung ke halaman sign-up atau sign-in.
- Beri hadiah untuk yang sudah menunggu, seperti masa trial lebih panjang atau diskon, dan sebutkan sekali saja.
- Kirim atas nama seseorang. Balasan adalah masukan awal terbaik yang akan Anda dapat.
- Kirim kalimat pembuka yang berbeda untuk founder dan engineer jika Anda mengumpulkan
role. Itulah alasan mengumpulkannya.
Pilih situasi Anda
Langkah di atas berlaku di mana saja. Halaman-halaman ini membahas apa yang berubah untuk peluncuran atau pembuat situs tertentu.
- Daftar peluncuran Product HuntKumpulkan daftar peluncuran berminggu-minggu sebelum Product Hunt, tandai setiap pendaftaran per channel, dan kirim email hari-H dalam dua gelombang mengikuti reset waktu Pasifik.
- Pendaftaran beta dengan penyaringanFormulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- Daftar tunggu aplikasi mobileDaftar tunggu pra-peluncuran untuk aplikasi iOS atau Android yang menanyakan ponsel yang dipakai, mengirim penguji iPhone ke TestFlight, dan mengisi closed test Play.
- Daftar tunggu Next.jsDaftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- Daftar tunggu di FramerDua cara mengumpulkan pendaftaran daftar tunggu di Framer: formulir Framer dengan webhook ke Ikiform, atau code component yang mempertahankan batas per pengunjung dan pemeriksaan spam.
- Daftar tunggu di WebflowArahkan Form Block Webflow ke Ikiform, tandai pendaftaran menurut sumbernya dengan field tersembunyi, dan pertahankan success state Webflow dengan skrip singkat untuk setiap formulir.
- Daftar tunggu di CarrdKumpulkan pendaftaran daftar tunggu di halaman Carrd: formulir Send to URL di Pro Plus dengan pelacakan UTM, atau formulir HTML yang di-embed dengan honeypot di Pro Standard.
- Daftar tunggu dengan HTML biasaHalaman daftar tunggu lengkap tanpa build dengan HTML biasa yang tetap berfungsi saat JavaScript mati, menampilkan ucapan terima kasih inline saat skrip dimuat, dan punya fallback bila perlu.
- Daftar tunggu AstroDaftar tunggu Astro di landing page statis: Action meneruskan setiap pendaftaran, skrip memanggilnya, dan satu halaman on-demand menangkap kiriman tanpa JavaScript.
- Daftar tunggu TanStack StartDaftar tunggu TanStack Start: satu server route menjawab kiriman formulir biasa maupun fetch, dan validateSearch membawa sumber dan hasilnya di URL.
- Daftar tunggu React RouterDaftar tunggu React Router di footer setiap halaman: fetcher mengirim ke satu route action, menandai setiap pendaftaran dengan halamannya, dan berfungsi tanpa JavaScript.
- Daftar tunggu SvelteKitDaftar tunggu SvelteKit 3: hook menyimpan ?ref= pertama di cookie, form action bernama meneruskan pendaftaran, dan pengunjung yang kembali melihat bahwa mereka sudah terdaftar.
- Daftar tunggu NuxtDaftar tunggu Nuxt di landing page yang di-prerender: satu route Nitro menerima $fetch dan kiriman formulir biasa, memeriksanya dengan Zod, dan me-redirect tanpa JavaScript.
Pastikan berfungsi
Jalankan langkah ini sebelum Anda membagikan halaman. 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. - Isi formulir di halaman Anda dan kirim. Anda akan 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.
- Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan Reply ditujukan ke pengirim jika formulir punya field
email. - Jika Anda menambahkan webhook, buka endpoint-nya di Webhooks. Pengirimannya menampilkan
succeededbeserta jawaban server Anda. - 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.
- Daftar dua kali dengan alamat yang sama dan pastikan tool email Anda tetap menampilkan satu kontak.
Pemecahan masalah
| Masalah | Yang harus dilakukan |
|---|---|
403 origin_not_allowed, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di Allowed domains formulir. Tambahkan, ditambah localhost untuk tes lokal. Subdomain dari domain yang tercantum diizinkan. |
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. |
403 challenge_required | Spam check formulir diatur ke Ikiform dan kiriman fetch tidak menyertakan token. Ambil token dengan challenge(formId) dari @ikiform/sdk sebelum setiap pengiriman, atau matikan pemeriksaannya. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak memicu apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
| 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. |
| Email yang sama muncul dua kali | Memang begitu. Ikiform tidak menghapus duplikat berdasarkan email. Hapus pengulangan saat ekspor, atau lakukan upsert berdasarkan email di relay atau tool email Anda. |
| Pendaftaran masuk ke Ikiform tetapi tidak ke tool email | Buka endpoint di Webhooks dan baca log pengirimannya. 400 berarti secret-nya salah; 502 dengan status dari tool Anda berarti tool itu menolak kontaknya. |
402 test_limit_reached | Workspace belum punya paket dan sudah memakai 50 tes localhost gratisnya bulan ini. Pilih paket, atau gunakan Send test di dashboard. |
Pertanyaan
Tidak perlu server sendiri. Formulir butuh tempat untuk mengirim, dan itulah fungsi form backend: arahkan action formulir ke endpoint Ikiform Anda, dan Ikiform menyimpan, menyaring, dan meneruskan setiap pendaftaran. Landing page Anda bisa tetap statis.
Tidak. Ikiform mengirim email ke Anda dan tim Anda, bukan ke orang yang mendaftar, jadi tidak ada email konfirmasi atau double opt-in. Kirim setiap pendaftaran lewat webhook ke tool email Anda dan aktifkan double opt-in di sana.
Pertahankan honeypot _gotcha yang tersembunyi, biarkan rate limit default aktif, tambahkan domain Anda di Allowed domains, dan atur spam check ke Ikiform untuk pemeriksaan Cloudflare Turnstile tanpa key yang perlu dikelola. Alamat sekali pakai juga menambah skor spam.
Bisa. Ikiform menyimpan setiap kiriman yang diterima dan tidak menggabungkan pengulangan berdasarkan email. Hapus duplikat di tempat daftar itu berada: tool email Anda, kolom unik di database Anda, atau CSV sebelum Anda mengimpornya.
Tidak dengan Ikiform saja. Ikiform tidak punya link referral atau leaderboard. Anda bisa mengirim kode referral di field tersembunyi dan menghitungnya sendiri, atau memakai produk daftar tunggu yang dibangun di sekitar referral jika itu inti peluncuran Anda.
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.