# Membuat daftar tunggu di TanStack Start

> Daftar tunggu TanStack Start: satu server route menjawab kiriman formulir biasa maupun fetch, dan validateSearch membawa sumber dan hasilnya di URL.

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

Tambahkan server route di `/api/waitlist` yang memvalidasi email, meneruskan pendaftaran ke backend formulir Anda dengan secret key, dan menjawab JSON untuk `fetch` atau redirect `303` untuk kiriman formulir biasa. Route landing membaca `?ref=`, `?joined=`, dan `?problem=` lewat `validateSearch`, sehingga tag sumber dan hasilnya tetap terbawa dengan atau tanpa JavaScript.

[Panduan utama](https://ikiform.com/id/guides/waitlist.md#form) punya tab TanStack Start yang dibangun dengan `createServerFn` dan `useServerFn`. Singkat, tetapi formulirnya baru berfungsi setelah React selesai hydrate, dan sumber setiap pendaftaran berupa string tetap. Halaman ini membangun versi yang layak Anda bagikan saat hari peluncuran: bisa mengirim sebelum hydration, menandai setiap pendaftaran dari tautan asalnya, dan menyimpan hasilnya di URL.

## Apa yang berbeda di TanStack Start

Start memberi Anda dua cara menjalankan kode di server, dan perilakunya berbeda untuk formulir:

|   | Server function | Server route |
| --- | --- | --- |
| Didefinisikan dengan | `createServerFn().handler()` | `createFileRoute()` dengan `server.handlers` |
| Dipanggil oleh | Komponen Anda, lewat panggilan RPC | Klien HTTP apa pun, termasuk `<form>` biasa |
| Jawaban | Diserialisasi untuk pemanggil | `Response` yang Anda buat sendiri |
| Tanpa JavaScript | Mungkin lewat `.url`-nya, tetapi Anda tidak mengendalikan jawabannya | Anda yang memilih: JSON, atau redirect kembali ke halaman |

Kiriman daftar tunggu datang dari dua jenis klien: browser yang menjalankan skrip Anda, dan browser yang belum memuatnya. Server route bisa menjawab masing-masing sesuai harapannya, dari satu handler. Header `Accept` membedakannya: skrip meminta `application/json`, sedangkan kiriman formulir biasa tidak.

Diagram: Cara server route TanStack Start menjawab setiap jenis kiriman

1. Route landing membaca ?ref= lewat validateSearch dan merendernya ke input tersembunyi.
2. Formulir mengirim FormData-nya ke server route, dengan fetch jika skrip sudah dimuat, atau sebagai kiriman biasa jika belum.
3. Route memeriksa email dan tag sumber, lalu meneruskan pendaftaran ke Ikiform dengan secret key.
4. Kiriman fetch meminta JSON, jadi mendapat { ok } atau pesan, dan komponen menggantinya dengan kalimat terima kasih atau error.
5. Kiriman biasa mendapat 303 kembali ke landing page, dengan ?joined=yes atau kode ?problem=, dan ref yang sama. validateSearch membacanya, dan halaman merender hasilnya.

Satu handler, dua jawaban. URL membawa hasilnya bagi pengunjung tanpa JavaScript.

## Siapkan proyek

Buat formulir dan secret key terlebih dahulu; [panduan server](https://ikiform.com/id/docs/guides/server.md) membahas keduanya. Berikut file yang Anda sentuh:

`Proyek`:

```text
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local
```

Simpan key di `.env.local`, yang dimuat Start saat development dan tidak masuk ke git. Kode server membacanya dari `process.env`. Jangan pernah memberinya prefix `VITE_`; prefix itu membundelnya ke kode browser.

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

Route mengirim `email` dan `ref`. Jika Anda menambahkan definisi field di tab **Fields** formulir, cantumkan keduanya:

`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

### Kode error, bukan teks error

`src/waitlist-messages.ts`:

```ts
export type Problem = "email" | "busy" | "failed";

export const MESSAGES: Record<Problem, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

export function isProblem(value: unknown): value is Problem {
  return typeof value === "string" && value in MESSAGES;
}
```

Redirect meletakkan kode singkat di URL, tidak pernah pesannya. Tautan dengan `?problem=` jadi hanya bisa menampilkan salah satu dari tiga kalimat Anda, bukan teks sembarang yang diketik orang asing ke URL.

### Server route-nya

`src/routes/api/waitlist.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";

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

async function forward(request: Request, form: FormData): Promise<Problem | null> {
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return "email";
  const ref = String(form.get("ref") ?? "");
  const origin = request.headers.get("origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return "failed";
  if (response.status === 429) return "busy";
  const result = await response.json().catch(() => null);
  if (result?.ok) return null;
  return result?.error?.fields?.email ? "email" : "failed";
}

export const Route = createFileRoute("/api/waitlist")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const form = await request.formData();
        const problem = await forward(request, form);

        if (request.headers.get("accept")?.includes("application/json")) {
          return problem
            ? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
            : Response.json({ ok: true });
        }

        const back = new URL("/", request.url);
        back.searchParams.set("ref", String(form.get("ref") ?? ""));
        if (problem) back.searchParams.set("problem", problem);
        else back.searchParams.set("joined", "yes");
        return Response.redirect(back, 303);
      },
    },
  },
});
```

- `request.formData()` membaca kedua encoding: `multipart/form-data` yang dikirim `fetch` dengan body `FormData`, dan body urlencoded dari kiriman biasa.
- `process.env` dibaca di dalam handler, saat request. Di Cloudflare Workers, nilainya hanya berisi secret Anda jika `nodejs_compat` aktif dan compatibility date 2025-04-01 atau setelahnya, seperti di `wrangler.jsonc` di bawah.
- Header `Origin` browser diteruskan karena Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu, dan `fetch` milik route tidak mengirimnya.
- `ref` harus berupa huruf kecil, angka, `-`, atau `_`, hingga 40 karakter, sesuai dengan `max_length` di definisi. Selain itu disimpan sebagai `direct`.
- `Response.redirect(back, 303)` memberi tahu browser untuk memuat landing page dengan GET, jadi refresh tidak mengirim pendaftaran dua kali.

### Route landing

`src/routes/index.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";

interface Search {
  ref?: string;
  joined?: boolean;
  problem?: Problem;
}

export const Route = createFileRoute("/")({
  validateSearch: (search: Record<string, unknown>): Search => ({
    ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
    joined: search.joined === "yes" ? true : undefined,
    problem: isProblem(search.problem) ? search.problem : undefined,
  }),
  component: Home,
});

function Home() {
  const search = Route.useSearch();
  const [joined, setJoined] = useState(Boolean(search.joined));
  const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError("");
    try {
      const response = await fetch("/api/waitlist", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const outcome = await response.json();
      if (outcome.ok) setJoined(true);
      else setError(outcome.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;

  return (
    <form method="post" action="/api/waitlist" onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
      <input type="hidden" name="ref" value={search.ref ?? "direct"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{error}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </form>
  );
}
```

- `validateSearch` mengubah query mentah menjadi objek bertipe. Nilai yang tidak dikenal menjadi `undefined`, jadi tautan yang rusak merender formulir biasa.
- Input tersembunyi `ref` diisi di server dari search params. Kiriman biasa membawanya tanpa skrip sama sekali.
- Formulir punya `method` dan `action` sungguhan. Jika bundle belum dimuat, browser mengirim ke server route dan mengikuti redirect.
- Setelah hydrate, `onSubmit` mengambil alih dan mengirim `Accept: application/json`, sehingga pengunjung tetap di halaman.

Honeypot ikut dalam `FormData` yang sama dan sampai ke Ikiform tanpa diubah, jadi bot yang mengisinya disimpan sebagai spam. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan **Spam check** tidak menambah apa pun di sini; route inilah penjaganya.

## Rendering dan deploy

Biarkan route landing dirender di server, yang merupakan default Start. Route ini membaca query string untuk merender `ref` dan hasilnya, sementara halaman yang di-prerender dibangun sekali tanpa query. Jika Anda mengaktifkan `prerender` di plugin `tanstackStart()` untuk halaman lain, kecualikan `/`.

| Host | Yang perlu ditambahkan | Secret-nya |
| --- | --- | --- |
| Vercel | Plugin Vite `nitro()` dari paket `nitro` | **Environment Variables** di pengaturan proyek |
| Netlify | `@netlify/vite-plugin-tanstack-start`, sebagai `netlify()` di plugin Vite | Di **Site configuration › Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin` dengan `viteEnvironment: { name: "ssr" }` dan `wrangler.jsonc` | `wrangler secret put IKIFORM_SECRET_KEY` |

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-waitlist",
  "compatibility_date": "2025-09-02",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry"
}
```

`nodejs_compat` membuat `process.env` tersedia di Workers. Tanpanya, key tidak ada dan setiap kiriman gagal dengan `failed`.

## Pastikan berfungsi

1. Jalankan `vite dev`, buka `http://localhost:3000/?ref=test`, dan mendaftar. Kalimat terima kasih menggantikan formulir tanpa reload.
2. Buka **Submissions** di dashboard. Pendaftaran ada di sana dengan `ref` bernilai `test`.
3. Matikan JavaScript, reload URL yang sama, dan mendaftar lagi. Address bar menampilkan `?joined=yes`, dan pendaftaran kedua juga memiliki `ref` bernilai `test`.
4. Dengan JavaScript mati, kirim `a@b` dan pastikan halaman kembali dengan pesan email dan `?problem=email`.
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 `vite 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 |
| --- | --- |
| Kiriman biasa menampilkan JSON mentah | Browser mengirim `application/json` di header `Accept`-nya, yang tidak pernah dilakukan kiriman formulir biasa. Pastikan tidak ada ekstensi atau proxy yang menulis ulang header itu, atau cocokkan berdasarkan header kustom yang dikirim `fetch`. |
| Setiap kiriman menjawab `failed` di production | `IKIFORM_SECRET_KEY` belum diatur di host. Di Workers, pastikan juga `nodejs_compat` aktif dan compatibility date 2025-04-01 atau setelahnya, jika tidak `process.env` tetap kosong. |
| Dua file mengklaim `/api/waitlist` | Start mengizinkan satu file handler per path. `api/waitlist.ts` dan `api/waitlist/index.ts` sama-sama mengarah ke path itu; pertahankan salah satunya. |
| `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

### Sebaiknya formulir TanStack Start memakai server function atau server route?

Keduanya bisa meneruskan pendaftaran. Server route memungkinkan Anda memilih jawaban untuk kiriman formulir biasa, seperti redirect kembali ke halaman, sehingga formulir berfungsi sebelum hydration. Server function cocok untuk formulir yang hanya berjalan setelah React dimuat.

### Bagaimana cara menyimpan sumber pendaftaran di TanStack Start?

Baca ?ref= dengan validateSearch di route landing, render ke input tersembunyi, dan periksa lagi di server sebelum meneruskannya. Redirect setelah kiriman biasa mengembalikannya ke URL.

### Bisakah landing page TanStack Start di-prerender?

Tidak, jika halaman itu merender sumber atau hasil dari query string, karena halaman yang di-prerender dibangun tanpa query string. Biarkan route itu dirender di server dan prerender sisanya.

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