Daftar tunggu untuk SaaS baru

Daftar tunggu untuk SaaS baru6 menit baca

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.

Preet SutharPendiri, Ikiform

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

ItemServer functionServer route
Didefinisikan dengancreateServerFn().handler()createFileRoute() dengan server.handlers
Dipanggil olehKomponen Anda, lewat panggilan RPCKlien HTTP apa pun, termasuk <form> biasa
JawabanDiserialisasi untuk pemanggilResponse yang Anda buat sendiri
Tanpa JavaScriptMungkin lewat .url-nya, tetapi Anda tidak mengendalikan jawabannyaAnda 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.

Cara server route TanStack Start menjawab setiap jenis kirimanroutelandingvalidateSearchmembaca?ref=POST/api/waitlistperiksaemail,teruskanIkiformsecret key,menyimpannyaJSONskripmenampilkanhasil303 ke /?joined=yesatau?problem=emailroutelandingmerenderhasilFormDataJSONAccept: jsonkiriman biasaCara server route TanStack Start menjawab setiap jenis kirimanroute landingvalidateSearchmembaca ?ref=POST/api/waitlistperiksa email,teruskanIkiformsecret key,menyimpannyaJSONskrip menampilkanhasil303 ke /?joined=yesatau?problem=emailroutelandingmerenderhasilFormDataJSONAccept: jsonkiriman biasa
  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 membahas keduanya. Berikut file yang Anda sentuh:

Proyek
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
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 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
{
  "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
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
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
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 /.

HostYang perlu ditambahkanSecret-nya
VercelPlugin Vite nitro() dari paket nitroEnvironment Variables di pengaturan proyek
Netlify@netlify/vite-plugin-tanstack-start, sebagai netlify() di plugin ViteDi Site configuration โ€บ Environment variables
Cloudflare Workers@cloudflare/vite-plugin dengan viteEnvironment: { name: "ssr" } dan wrangler.jsoncwrangler secret put IKIFORM_SECRET_KEY
wrangler.jsonc
{
  "$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.

Jebakan

MasalahYang harus dilakukan
Kiriman biasa menampilkan JSON mentahBrowser 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 productionIKIFORM_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/waitlistStart 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_fieldDefinisi field tidak mencantumkan ref. Tambahkan seperti pada definisi di atas.
402 plan_requiredWorkspace 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

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.