# Membuat daftar tunggu di React Router

> Daftar tunggu React Router di footer setiap halaman: fetcher mengirim ke satu route action, menandai setiap pendaftaran dengan halamannya, dan berfungsi tanpa JavaScript.

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

Beri daftar tunggu route-nya sendiri dengan `action` yang memvalidasi email dan meneruskannya ke backend formulir Anda dengan secret key. Render formulir di layout dengan `useFetcher`, sehingga pendaftaran dari halaman mana pun tetap di halaman itu dan ditandai dengan path-nya; tanpa JavaScript, formulir yang sama mengirim ke route daftar tunggu dan halamannya menampilkan hasilnya.

[Panduan utama](https://ikiform.com/id/guides/waitlist.md#form) punya tab React Router dengan formulir dan `action`-nya di route beranda. Itu cocok untuk situs satu halaman. Kebanyakan situs SaaS sebelum peluncuran punya beberapa halaman, seperti harga, posting blog, dan beranda, dan Anda ingin pendaftaran ada di semuanya serta tahu mana yang berhasil. Halaman ini meletakkan formulir di layout dan mengirim setiap salinannya ke satu action.

## Apa yang berbeda di React Router

Di framework mode, action dimiliki oleh sebuah route, dan `<Form>` mengirim ke route tempat ia berada. Formulir di layout berada di setiap route, jadi pilihan komponen menentukan apa yang terjadi saat submit:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| Tujuan kiriman | Route saat ini, kecuali Anda mengatur `action` | Action route `/waitlist` |
| Setelah submit | Navigasi. URL dan history berubah | Tanpa navigasi. Pengunjung tetap di halaman |
| Hasilnya | `actionData` dari route yang dijalankan | `fetcher.data`, dibagi oleh setiap fetcher dengan `key` yang sama |
| Tanpa JavaScript | Kiriman biasa | Kiriman biasa ke `/waitlist`, yang merender halamannya sendiri |

Fetcher cocok untuk pendaftaran di footer: tidak ada yang mau meninggalkan halaman harga demi bergabung ke daftar. Fetcher juga turun kelas dengan rapi. Sebelum hydration, `fetcher.Form` adalah `<form>` biasa yang mengirim ke `/waitlist`, jadi route itu memerlukan halamannya sendiri untuk menampilkan hasil.

Diagram: Pendaftaran footer dari setiap halaman React Router melewati satu route action

1. Layout merender formulir daftar tunggu di setiap halaman, dengan ref tersembunyi yang dibuat dari path halaman.
2. Dengan JavaScript, fetcher.Form mengirim ke /waitlist tanpa meninggalkan halaman. Key fetcher dibagi, jadi catatan di header melihat hasil yang sama.
3. Tanpa JavaScript, formulir yang sama mengirim ke /waitlist sebagai navigasi biasa.
4. Apa pun jalurnya, action /waitlist memeriksa email dan meneruskan pendaftaran ke Ikiform dengan secret key.
5. Fetcher membaca hasil dari fetcher.data. Kiriman biasa mendarat di halaman /waitlist, yang menampilkannya dari actionData.

Satu action, berapa pun formulirnya. Path memberi tahu halaman mana yang menghasilkan pendaftaran.

## Siapkan proyek

[Panduan server](https://ikiform.com/id/docs/guides/server.md) membahas pembuatan formulir dan secret key. File di halaman ini:

`Proyek`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

Letakkan key di `.env` dan baca dari `process.env` di action. Modul route dipecah untuk browser, tetapi `action` hanya berjalan di server, jadi key tidak pernah dikirim ke klien. Jangan pernah mengimpornya ke komponen.

`.env`:

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

Action mengirim `email` dan `ref`. Jika formulir punya definisi field di tab **Fields**, keduanya harus dicantumkan:

`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 } }
  ]
}
```

## Bangun

### Daftarkan route

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### Route daftar tunggu

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  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.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` mengatur status error tanpa mengubahnya menjadi response yang dilempar, jadi komponen tetap merender pesannya.
- Setiap jawaban mengembalikan email, sehingga input bisa menampilkannya lagi setelah error.
- Header `Origin` browser diteruskan karena Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu, dan `fetch` milik action tidak mengirimnya.
- `ref` harus berupa huruf kecil, angka, `-`, atau `_`, hingga 40 karakter, sama dengan `max_length` di definisi. Selain itu disimpan sebagai `direct`.
- Default export adalah fallback tanpa JavaScript. Ia merender formulir yang sama dengan hasil action.

### Formulir bersama

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

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` membuat setiap fetcher dengan key itu menjadi satu fetcher bersama. `JoinedNote` di header membaca `fetcher.data` yang sama, jadi berubah begitu formulir footer berhasil.
- `sourceFor` mengubah `/blog/launch` menjadi `blog-launch` dan `/` menjadi `home`. Path diketahui di server, jadi ini juga berfungsi di halaman yang di-prerender.
- `fetcher.state` bernilai `submitting` selama kiriman berjalan, lalu `loading` selama React Router melakukan revalidasi, jadi tombol tetap nonaktif sampai keduanya selesai.
- Label membungkus input, jadi salinan di hero dan footer pada satu halaman tidak berbagi `id`.

Render `<WaitlistForm />` di `Layout` milik `app/root.tsx`, di dalam footer dengan `id="waitlist"`, dan `<JoinedNote />` di header Anda. Honeypot ikut terkirim dan sampai ke Ikiform tanpa diubah. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan **Spam check** tidak menambah apa pun untuk formulir ini.

### Lewati reload setiap loader

Setelah action, React Router memuat ulang data setiap route di halaman. Pendaftaran tidak mengubah data itu sama sekali. Tambahkan ini ke route yang loader-nya lambat atau berbiaya:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## Rendering dan deploy

Halaman marketing tidak berubah per pengunjung, jadi prerender halaman itu dan simpan server untuk action:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

Pertahankan `ssr: true`. Dengan `ssr: false`, tidak ada server runtime, dan React Router menggagalkan build untuk route apa pun yang mengekspor `action`. Dengan `ssr: true`, `/` dan `/pricing` ditulis sebagai HTML saat build, dan `/waitlist` tetap berjalan di server.

| Host | Yang perlu ditambahkan | Membaca key |
| --- | --- | --- |
| Vercel | `@vercel/react-router`, dengan `presets: [vercelPreset()]` di `react-router.config.ts` | `process.env`, diatur di **Environment Variables** |
| Netlify | `@netlify/vite-plugin-react-router`, sebagai `netlifyReactRouter()` di plugin Vite | `process.env`, diatur di **Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin`, sebagai `cloudflare()` sebelum `reactRouter()` | `env.IKIFORM_SECRET_KEY` dari `cloudflare:workers`, diatur dengan `wrangler secret put` |

Di Cloudflare, gunakan `import { env } from "cloudflare:workers"` dan baca key dari sana, bukan dari `process.env`. Di React Router 8, `context` milik action adalah `RouterContextProvider`, jadi pola `context.cloudflare.env` dari v7 tidak berlaku lagi. Sisa action tetap sama.

## Pastikan berfungsi

1. Jalankan `react-router dev`, buka `http://localhost:5173/pricing`, dan mendaftar di footer. Kalimat terima kasih menggantikan formulir, dan catatan di header ikut berubah.
2. Buka **Submissions** di dashboard. Pendaftaran memiliki `ref` bernilai `pricing`.
3. Matikan JavaScript dan mendaftar dari beranda. Anda mendarat di `/waitlist` dengan kalimat terima kasih, dan pendaftaran memiliki `ref` bernilai `home`.
4. Kirim `a@b` dari footer. Error muncul di bawah input dengan alamat yang masih terketik.
5. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
6. Kiriman dari `react-router dev` membawa origin `localhost`, jadi dihitung sebagai kiriman uji. 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).

## Jebakan

| Masalah | Yang harus dilakukan |
| --- | --- |
| Pendaftaran di footer bernavigasi ke `/waitlist` meskipun JavaScript aktif | Formulirnya adalah `<Form>` atau `<form>` biasa, bukan `fetcher.Form`. Gunakan fetcher, dan jangan mengembalikan `redirect()` dari action; redirect dari action milik fetcher akan bernavigasi. |
| Build gagal dengan error tentang ekspor `action` | `ssr` bernilai `false` di `react-router.config.ts`. Ubah ke `true`, atau daftar tunggu tidak punya server untuk berjalan. |
| `ref` setiap pendaftaran adalah `waitlist` | Kiriman berasal dari halaman `/waitlist` itu sendiri. Itu halaman fallback; tautan harus mengarah ke halaman Anda yang sebenarnya. |
| `422` dengan `unknown_field` | Definisi field tidak mencantumkan `ref`. Tambahkan seperti pada definisi di atas. |
| `402 plan_required` | Workspace tidak punya paket dan kiriman berasal dari situs live. Kiriman ditahan, tidak hilang. Pilih paket atau beli paket kiriman di **Settings › Plan and usage**, dan kiriman yang ditahan akan masuk. |

## Pertanyaan

### Bagaimana cara mengirim formulir React Router tanpa bernavigasi?

Gunakan useFetcher dan render fetcher.Form dengan action dari route yang menanganinya. Pengunjung tetap di halaman dan hasilnya tiba di fetcher.data. Sebelum hydration, formulir mengirim seperti formulir biasa.

### Bisakah halaman React Router yang di-prerender punya formulir yang berfungsi?

Bisa, jika ssr bernilai true. Halaman yang di-prerender disajikan sebagai HTML, dan formulirnya mengirim ke action yang berjalan di server. Jika ssr diatur ke false, action tidak diizinkan.

### Bagaimana saya tahu dari halaman mana pendaftaran React Router berasal?

Letakkan path halaman di input tersembunyi ref, seperti yang dilakukan sourceFor di atas. Path diketahui di server dan klien, jadi berfungsi di halaman yang di-prerender dan sebelum hydration.

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