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

Terbit 2026-10-25 · https://ikiform.com/id/guides/waitlist

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.

Diagram: Bagaimana pendaftaran daftar tunggu berpindah dari landing page ke tool email Anda

1. Pengunjung memasukkan emailnya di landing page Anda, dan formulir mengirimnya ke endpoint Ikiform Anda.
2. Ikiform memeriksa honeypot, skor spam, dan rate limit, lalu menyimpan pendaftarannya.
3. Pendaftaran muncul di dashboard, tempat Anda bisa mencarinya dan mengekspor daftarnya sebagai CSV.
4. Ikiform mengirim email ke Anda dan tim Anda. Ikiform tidak pernah mengirim email ke orang yang mendaftar.
5. Ikiform mengirim webhook submission.created bertanda tangan.
6. Relay atau tool otomatisasi menambahkan alamatnya ke tool email Anda, yang mengirim konfirmasi dan, nanti, email peluncuran.

Tool email tidak wajib di hari pertama. Anda bisa mengumpulkan pendaftaran selama berminggu-minggu lalu mengimpor CSV nanti; webhook menjaga keduanya tetap sinkron sejak awal.

## 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](https://ikiform.com/alternatives/typeform) atau [Tally](https://ikiform.com/alternatives/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.

**HTML**

Tempel formulir di tempat pendaftaran harus muncul. Formulir mengirim dengan cara biasa lalu redirect, jadi tetap berfungsi tanpa JavaScript sama sekali.

`index.html`:

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

`waitlist.js`:

```js
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](https://ikiform.com/id/guides/waitlist/html.md).

**Next.js**

Server action meneruskan pendaftaran dengan secret key Anda, dan `useActionState` menampilkan hasilnya tanpa memuat ulang halaman. Formulir tetap terkirim meskipun JavaScript belum dimuat.

`app/waitlist/actions.ts`:

```ts
"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,
  };
}
```

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

```tsx
"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](https://ikiform.com/id/guides/waitlist/nextjs.md).

**Astro**

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

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/pages/index.astro`:

```astro
---
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](https://ikiform.com/id/guides/waitlist/astro.md).

**TanStack Start**

Fungsi POST `createServerFn` menerima `FormData`, memeriksanya di `.validator()`, lalu meneruskannya dari server. Route memanggilnya lewat `useServerFn`.

`src/routes/index.tsx`:

```tsx
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](https://ikiform.com/id/guides/waitlist/tanstack-start.md).

**React Router**

`action` milik route meneruskan kiriman, dan `actionData` membawa hasilnya kembali ke komponen. `data()` mempertahankan kode status Ikiform saat terjadi error.

`app/routes/home.tsx`:

```tsx
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](https://ikiform.com/id/guides/waitlist/react-router.md).

**SvelteKit**

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.

`src/routes/+page.server.ts`:

```ts
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;
```

`src/routes/+page.svelte`:

```svelte
<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](https://ikiform.com/id/guides/waitlist/sveltekit.md).

**Nuxt**

Server route menjaga secret key tetap di luar browser. Atur `NUXT_IKIFORM_SECRET_KEY` di environment, dan Nuxt mengisi `runtimeConfig.ikiformSecretKey` darinya.

`server/api/waitlist.post.ts`:

```ts
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 };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`app/components/WaitlistForm.vue`:

```vue
<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](https://ikiform.com/id/guides/waitlist/nuxt.md).

**React**

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.

`src/WaitlistForm.tsx`:

```tsx
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>
  );
}
```

**Vue**

Kiriman sisi browser yang sama dalam single-file component Vue. Formulir tetap punya `action`, jadi masih berfungsi jika skripnya gagal dimuat.

`src/components/WaitlistForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` dengan ID dari dashboard.
- `name="email"` adalah ID field. Biarkan tetap `email`: Ikiform memakainya sebagai alamat reply-to di email notifikasi, dan mencocokkannya dengan daftar domain email sekali pakai.
- Input tersembunyi `_gotcha` adalah honeypot. Orang tidak pernah melihatnya. Bot yang mengisi setiap input menandai dirinya sendiri sebagai spam, dan versi server meneruskannya tanpa diubah.
- Input tersembunyi `ref` mencatat 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 `422` dan error `unknown_field`, jadi cantumkan `ref` selain `email`, 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.

`Field`:

```json
{
  "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 } }
  ]
}
```

> Versi sisi server mengirim dengan secret key, jadi Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Setiap kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sekaligus. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

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](https://ikiform.com/id/docs/guides/nextjs.md), [Astro](https://ikiform.com/id/docs/guides/astro.md), [SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/id/docs/guides/nuxt.md), [React](https://ikiform.com/id/docs/guides/react.md), [Vue](https://ikiform.com/id/docs/guides/vue.md), dan [HTML biasa](https://ikiform.com/id/docs/html.md). [SDK](https://ikiform.com/id/docs/sdk.md) membungkus request browser sebagai `submit()`, dan [template formulir daftar tunggu](https://ikiform.com/forms/waitlist) 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.

Formulir: Waitlist

- **Email** (`email`, email, wajib)
- **Nama** (`name`, text)
- **Apa pekerjaan Anda?** (`role`, select)
- **Dari mana Anda tahu tentang kami?** (`source`, text)
- **Sumber** (`ref`, text)

Field dari [template daftar tunggu](https://ikiform.com/forms/waitlist) ditambah `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 tersembunyi`:

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

> Jika Anda akan mengirim apa pun selain kabar peluncuran, misalnya newsletter, tambahkan checkbox persetujuan wajib dengan janji yang persis sebagai labelnya. Ikiform menyimpan `true` bersama timestamp kiriman, yang mencatat kapan orang itu setuju. [Template newsletter](https://ikiform.com/forms/newsletter-signup) 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](https://ikiform.com/id/docs/spam.md#human-check) menunjukkan keempat barisnya.

> Kiriman saat peluncuran datang bergelombang. Jika Anda membagikan halaman di tempat besar, batas seluruh formulir 300 per menit bisa tercapai sebelum batas per pengunjung mana pun. Naikkan dengan rate limit Custom sehari sebelumnya. Satu kantor atau konferensi berbagi satu IP, jadi Relaxed, 20 per menit per pengunjung, cocok untuk hari-hari itu.

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

1. **Di tool email.** Kebanyakan tool email mengenali kontak dari alamatnya, jadi menambahkan alamat yang sama dua kali hanya memperbarui satu kontak, bukan membuat dua.
2. **Saat ekspor.** Hapus email duplikat dari CSV sebelum Anda mengimpornya ke mana pun. Ada dua cara di bawah.
3. **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](https://ikiform.com/id/docs/frameworks.md#idempotency) 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](https://ikiform.com/id/docs/webhooks.md). 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](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md), atau [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).
- **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.

`app/api/waitlist/route.ts`:

```ts
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;
  }
}
```

- `verifyWebhook` memeriksa tanda tangan dan timestamp, jadi tidak ada orang lain yang bisa menambahkan alamat lewat URL ini.
- Ganti `fetch` dengan 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 `204` memberi tahu Ikiform bahwa pengiriman berhasil. `502` membuat Ikiform mencoba ulang, dengan `webhook-id` yang 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](https://ikiform.com/id/docs/webhooks.md) membahas percobaan ulang dan payload, dan [server Anda sendiri](https://ikiform.com/blog/form-submissions-to-your-own-server.md) punya versi yang menyimpan setiap pengiriman sekali saja.

## Hari peluncuran

Diagram: Daftar tunggu dari pendaftaran pertama hingga hari peluncuran

1. Halaman tayang dengan formulir yang mengirim ke Ikiform.
2. Setiap minggu, bagikan halamannya dan baca pendaftaran baru beserta jawabannya.
3. Seminggu sebelum peluncuran, ekspor daftar sebagai CSV, hapus duplikat, dan bagi berdasarkan peran.
4. Di hari peluncuran, kirim satu email ke daftar dari tool email Anda.

Sebagian besar pekerjaan ada di ekspor seminggu sebelumnya. Email peluncurannya sendiri singkat.

### 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](https://ikiform.com/id/docs/guides/export.md#api).

Untuk menghapus duplikat di Google Sheets, impor CSV-nya dan gunakan rumus ini di sheet kosong, dengan email di kolom E:

`Google Sheets`:

```text
=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:

`dedupe.mjs`:

```js
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:

`Email peluncuran`:

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

> Jika Anda mengumpulkan selama berbulan-bulan tanpa mengonfirmasi alamat, kirim dulu ke sebagian kecil dan pantau bounce rate-nya. Daftar yang penuh salah ketik dan alamat mati merusak domain yang akan Anda pakai untuk mengirim email produk.

## 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 Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan 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 penyaringan](https://ikiform.com/id/guides/waitlist/beta-signup.md): Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar 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.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu di Framer](https://ikiform.com/id/guides/waitlist/framer.md): Dua 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 Webflow](https://ikiform.com/id/guides/waitlist/webflow.md): Arahkan 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 Carrd](https://ikiform.com/id/guides/waitlist/carrd.md): Kumpulkan 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 biasa](https://ikiform.com/id/guides/waitlist/html.md): Halaman 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 Astro](https://ikiform.com/id/guides/waitlist/astro.md): Daftar tunggu Astro di landing page statis: Action meneruskan setiap pendaftaran, skrip memanggilnya, dan satu halaman on-demand menangkap kiriman tanpa JavaScript.
- [Daftar tunggu TanStack Start](https://ikiform.com/id/guides/waitlist/tanstack-start.md): Daftar tunggu TanStack Start: satu server route menjawab kiriman formulir biasa maupun fetch, dan validateSearch membawa sumber dan hasilnya di URL.
- [Daftar tunggu React Router](https://ikiform.com/id/guides/waitlist/react-router.md): Daftar tunggu React Router di footer setiap halaman: fetcher mengirim ke satu route action, menandai setiap pendaftaran dengan halamannya, dan berfungsi tanpa JavaScript.
- [Daftar tunggu SvelteKit](https://ikiform.com/id/guides/waitlist/sveltekit.md): Daftar 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 Nuxt](https://ikiform.com/id/guides/waitlist/nuxt.md): Daftar 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](https://ikiform.com/id/pricing).

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Isi formulir di halaman Anda dan kirim. Anda akan mendarat di halaman terima kasih Anda, atau halaman hosted milik Ikiform.
3. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
4. Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan **Reply** ditujukan ke pengirim jika formulir punya field `email`.
5. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengirimannya menampilkan `succeeded` beserta jawaban server Anda.
6. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
7. Tidak ada yang masuk? Ubah filter status di dashboard ke **Spam**. Spam mendapat jawaban sukses yang sama dengan kiriman asli.
8. 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

### Apakah saya butuh backend untuk membuat halaman daftar tunggu?

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.

### Apakah Ikiform mengirim email konfirmasi ke orang yang bergabung dengan daftar tunggu?

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.

### Bagaimana cara mencegah pendaftaran palsu di formulir daftar tunggu?

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.

### Apakah email yang sama akan disimpan dua kali?

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.

### Bisakah saya menambahkan program referral atau posisi antrean?

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.

### Apakah Ikiform gratis?

Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.

## Panduan lainnya

- [Daftar peluncuran Product Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan 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 penyaringan](https://ikiform.com/id/guides/waitlist/beta-signup.md): Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar 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.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu di Framer](https://ikiform.com/id/guides/waitlist/framer.md): Dua 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 Webflow](https://ikiform.com/id/guides/waitlist/webflow.md): Arahkan 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 Carrd](https://ikiform.com/id/guides/waitlist/carrd.md): Kumpulkan 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 biasa](https://ikiform.com/id/guides/waitlist/html.md): Halaman 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 Astro](https://ikiform.com/id/guides/waitlist/astro.md): Daftar tunggu Astro di landing page statis: Action meneruskan setiap pendaftaran, skrip memanggilnya, dan satu halaman on-demand menangkap kiriman tanpa JavaScript.
- [Daftar tunggu TanStack Start](https://ikiform.com/id/guides/waitlist/tanstack-start.md): Daftar tunggu TanStack Start: satu server route menjawab kiriman formulir biasa maupun fetch, dan validateSearch membawa sumber dan hasilnya di URL.
- [Daftar tunggu React Router](https://ikiform.com/id/guides/waitlist/react-router.md): Daftar tunggu React Router di footer setiap halaman: fetcher mengirim ke satu route action, menandai setiap pendaftaran dengan halamannya, dan berfungsi tanpa JavaScript.
- [Daftar tunggu SvelteKit](https://ikiform.com/id/guides/waitlist/sveltekit.md): Daftar 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 Nuxt](https://ikiform.com/id/guides/waitlist/nuxt.md): Daftar 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.
- [Formulir kontak di situs statis](https://ikiform.com/id/guides/contact-form-static-site.md): Pasang formulir kontak yang berfungsi di situs statis tanpa server: ke mana POST dikirim, spam, balasan lewat email, dan kode untuk setiap host dan framework.
- [Kirim formulir ke email Anda](https://ikiform.com/id/guides/send-form-to-email.md): Terima setiap kiriman formulir di inbox tanpa mail server: mengapa mailto: dan mail() PHP gagal, dan setup yang menyaring spam serta memungkinkan Anda membalas.
- [Hentikan spam formulir kontak](https://ikiform.com/id/guides/spam-protection.md): Hentikan spam formulir kontak dengan lapisan yang tidak membebani orang sungguhan: honeypot, waktu pengisian, skor konten, rate limit, dan pemeriksaan manusia hanya bila perlu.
