# Cara membuat daftar tunggu Next.js dengan server action

> Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.

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

Tulis server action yang memeriksa email, menerapkan batas per pengunjung, dan meneruskan pendaftaran ke form backend Anda dengan secret key. Panggil lewat `useActionState` agar error kembali sebagai state, dan bungkus dengan `useOptimistic` agar kalimat terima kasih muncul begitu tombol ditekan dan dibatalkan jika kiriman gagal.

[Panduan utama](https://ikiform.com/id/guides/waitlist.md#form) punya tab Next.js singkat: server action dan `useActionState`. Itu cukup untuk landing page yang sepi. Halaman ini melangkah lebih jauh, untuk daftar tunggu yang akan Anda bagikan secara luas: terima kasih instan, pesan error yang jujur, dan batas per pengunjung yang sebaliknya hilang pada kiriman dari sisi server.

## Apa yang diubah oleh server action

Server action menjaga secret key di server Anda dan memungkinkan Anda memeriksa email sebelum apa pun keluar dari aplikasi Anda. Server action juga memindahkan setiap kiriman ke alamat IP server Anda, dan itu punya tiga efek:

|   | Kiriman browser ke Ikiform | Server action dengan secret key |
| --- | --- | --- |
| Siapa yang dilihat Ikiform | IP setiap pengunjung | IP server Anda, untuk setiap pengunjung |
| Rate limit per pengunjung | Milik Ikiform, per IP | Anda yang menambahkan, ditunjukkan di bawah |
| Domain yang diizinkan dan pemeriksaan manusia | Dijalankan | Dilewati, karena key membuktikan kiriman itu milik Anda |
| Siapa yang bisa memanggilnya | Siapa saja, dari domain yang diizinkan | Siapa saja yang bisa mengirim POST ke halaman Anda |

Baris terakhir itu penting. Next.js memperlakukan server action sebagai endpoint publik: siapa saja bisa memanggilnya dengan POST buatan, bukan hanya formulir Anda. Karena secret key melewati pemeriksaan origin Ikiform, action Anda menjadi satu-satunya gerbang, jadi action itu butuh batasnya sendiri.

Diagram: Bagaimana pendaftaran bergerak melalui server action Next.js

1. Pengunjung menekan tombol. useOptimistic langsung mengganti formulir dengan kalimat terima kasih.
2. Server action memeriksa email dan menghitung percobaan terbaru pengunjung berdasarkan IP.
3. Jika masih dalam batas, action mengirim pendaftaran ke Ikiform dengan secret key.
4. Hasilnya menjadi action state. Jika berhasil, terima kasih tetap tampil; jika error, formulir kembali dengan pesan dan email yang sudah diketik.
5. Jika melebihi batas, tidak ada yang dikirim dan state error langsung kembali.

Kalimat optimistis hanya bertahan selama action berjalan. Action state yang menentukan apa yang tetap di layar.

## Bangun

### Secret key

Di dashboard, buka **API keys**, buat secret key dengan scope `submissions:write`, dan simpan sebagai variabel khusus server. Jangan pernah memberinya awalan `NEXT_PUBLIC_`.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

### Batas per pengunjung

Lima pendaftaran per menit per IP sangat longgar untuk manusia dan tidak berguna untuk skrip. Penghitung ini tinggal di memori server. `server-only` membuat build gagal jika komponen klien mengimpornya.

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- Memori tidak dibagi antar-instance server. Di satu server Node atau container, hitungannya tepat. Di hosting serverless, setiap instance menyimpan hitungannya sendiri, jadi anggap ini sebagai polisi tidur dan pindahkan penghitung ke penyimpanan bersama seperti Redis saat trafik bertambah.
- Hanya percayai header IP yang diatur oleh host Anda. Vercel mengatur `x-forwarded-for`; di belakang Cloudflare, baca `cf-connecting-ip`. Header yang bisa diatur sendiri oleh pengunjung sama sekali bukan batas.

### Server action

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    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: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- Pemeriksaan yang murah berjalan lebih dulu. Email yang buruk tidak pernah memakan biaya request.
- `AbortSignal.timeout` mencegah jaringan yang lambat menahan tombol dalam status pending selamanya.
- `429` dari Ikiform dibaca terpisah, bersama `Retry-After`-nya, karena dengan semua pengunjung di belakang satu IP, yang sibuk adalah seluruh formulir, bukan orang ini.
- Setiap error mengembalikan email, agar input bisa menampilkannya lagi. React mereset formulir setelah action, dan `defaultValue` mengembalikan alamatnya.
- Header `Origin` dari browser diteruskan karena Ikiform menghitung kiriman uji gratis berdasarkan header itu, sedangkan `fetch` milik server tidak mengirimnya.
- `submit()` dari SDK tidak mengirim header `Authorization`, jadi tidak bisa melewati domain yang diizinkan. Gunakan `fetch` dengan key di sini.

### Formulir, dengan terima kasih optimistis

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

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- `useOptimistic` harus diatur di dalam action. `join` adalah action formulir, jadi React menjalankannya dalam transition dan kalimat optimistis bertahan sampai `dispatch` selesai.
- Jika berhasil, action state menjadi `joined` dan kalimatnya tetap, kini dengan pesan akhir. Jika error, nilai optimistis hilang, dan formulir kembali dengan `state.email` serta pesannya.
- `aria-busy` memberi tahu screen reader bahwa kalimat itu masih dikonfirmasi.

> Membungkus action di dalam `join` menjadikannya fungsi klien, jadi formulir ini butuh JavaScript. Jika kemampuan mengirim sebelum hidrasi lebih penting daripada terima kasih instan, berikan `dispatch` langsung ke `action` dan hapus `useOptimistic`; sisa kodenya tetap sama.

### Halamannya

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

Halaman membaca `?ref=` dari URL dan memberikannya ke formulir, jadi link seperti `/waitlist?ref=newsletter` menandai setiap pendaftaran dengan sumbernya. `PageProps` adalah tipe yang sadar route yang dihasilkan Next.js; membaca `searchParams` membuat halaman dirender per request.

## Pastikan berfungsi

1. Jalankan `next dev`, buka `/waitlist?ref=test`, lalu daftar. Kalimat terima kasih muncul sebelum request selesai.
2. Buka **Submissions** di dashboard. Pendaftaran ada di sana dengan `ref` bernilai `test`.
3. Kirim enam kali dalam satu menit. Yang keenam menampilkan pesan batas, dan hanya lima kiriman yang disimpan.
4. Atur `IKIFORM_SECRET_KEY` ke nilai yang salah lalu kirim. Kalimat optimistis dibatalkan dan formulir menampilkan error dengan email Anda masih terisi.
5. Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan **Reply** ditujukan ke pengirim jika formulir punya field `email`.
6. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

## Jebakan

| Masalah | Yang harus dilakukan |
| --- | --- |
| `403 forbidden` dari Ikiform | Key berasal dari workspace lain, sudah dicabut, atau tidak punya `submissions:write`. Buat secret key dengan scope itu. |
| `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. |
| Semua pengunjung terkena batas sekaligus | Header IP tidak ada, jadi semua orang dihitung sebagai `unknown`. Baca header yang diatur host Anda, seperti `cf-connecting-ip` di belakang Cloudflare. |
| Terima kasih berkedip lalu formulir kembali tanpa pesan | Action melempar error alih-alih mengembalikan state. Jadikan setiap jalur kegagalan sebagai `JoinState` yang dikembalikan, seperti di contoh. |
| Peringatan bahwa optimistic update terjadi di luar transition | `showJoined` dipanggil dari event handler. Panggil di dalam action formulir, seperti yang dilakukan `join`. |

## Pertanyaan

### Sebaiknya daftar tunggu Next.js mengirim dari browser atau dari server action?

Keduanya bisa. Kiriman dari browser mempertahankan batas per pengunjung, domain yang diizinkan, dan pemeriksaan manusia dari Ikiform. Server action menyembunyikan endpoint, memeriksa input lebih dulu, dan berfungsi sebelum hidrasi, tetapi Anda harus menambahkan batas per pengunjung sendiri karena setiap kiriman datang dari server Anda.

### Apakah useOptimistic bisa dipakai bersama useActionState?

Bisa. Atur nilai optimistis dan panggil fungsi dispatch dari useActionState di dalam action yang sama, seperti prop action milik formulir. Nilai optimistis tampil selama action berjalan dan digantikan oleh action state saat selesai.

### Apakah saya butuh database untuk daftar tunggu Next.js?

Tidak. Server action meneruskan setiap pendaftaran ke Ikiform, yang menyimpannya, menyaring spam, mengirimi Anda email, dan mengirim webhook. Aplikasi Anda tidak menyimpan state apa pun selain penghitung rate limit.

### 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 tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [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.
- [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 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.
- [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 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.
