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.
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:
| Item | 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.
- Route landing membaca ?ref= lewat validateSearch dan merendernya ke input tersembunyi.
- Formulir mengirim FormData-nya ke server route, dengan fetch jika skrip sudah dimuat, atau sebagai kiriman biasa jika belum.
- Route memeriksa email dan tag sumber, lalu meneruskan pendaftaran ke Ikiform dengan secret key.
- Kiriman fetch meminta JSON, jadi mendapat { ok } atau pesan, dan komponen menggantinya dengan kalimat terima kasih atau error.
- Kiriman biasa mendapat 303 kembali ke landing page, dengan ?joined=yes atau kode ?problem=, dan ref yang sama. validateSearch membacanya, dan halaman merender hasilnya.
Siapkan proyek
Buat formulir dan secret key terlebih dahulu; panduan server membahas keduanya. Berikut file yang Anda sentuh:
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localSimpan 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY di environment variable khusus server.Route mengirim email dan ref. Jika Anda menambahkan definisi field di tab Fields formulir, cantumkan keduanya:
{
"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
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
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-datayang dikirimfetchdengan bodyFormData, dan body urlencoded dari kiriman biasa.process.envdibaca di dalam handler, saat request. Di Cloudflare Workers, nilainya hanya berisi secret Anda jikanodejs_compataktif dan compatibility date 2025-04-01 atau setelahnya, seperti diwrangler.jsoncdi bawah.- Header
Originbrowser diteruskan karena Ikiform menghitung kiriman uji gratis darilocalhostberdasarkan header itu, danfetchmilik route tidak mengirimnya. refharus berupa huruf kecil, angka,-, atau_, hingga 40 karakter, sesuai denganmax_lengthdi definisi. Selain itu disimpan sebagaidirect.Response.redirect(back, 303)memberi tahu browser untuk memuat landing page dengan GET, jadi refresh tidak mengirim pendaftaran dua kali.
Route landing
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>
);
}validateSearchmengubah query mentah menjadi objek bertipe. Nilai yang tidak dikenal menjadiundefined, jadi tautan yang rusak merender formulir biasa.- Input tersembunyi
refdiisi di server dari search params. Kiriman biasa membawanya tanpa skrip sama sekali. - Formulir punya
methoddanactionsungguhan. Jika bundle belum dimuat, browser mengirim ke server route dan mengikuti redirect. - Setelah hydrate,
onSubmitmengambil alih dan mengirimAccept: 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 |
{
"$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
- Jalankan
vite dev, bukahttp://localhost:3000/?ref=test, dan mendaftar. Kalimat terima kasih menggantikan formulir tanpa reload. - Buka Submissions di dashboard. Pendaftaran ada di sana dengan
refbernilaitest. - Matikan JavaScript, reload URL yang sama, dan mendaftar lagi. Address bar menampilkan
?joined=yes, dan pendaftaran kedua juga memilikirefbernilaitest. - Dengan JavaScript mati, kirim
a@bdan pastikan halaman kembali dengan pesan email dan?problem=email. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Kiriman dari
vite devmembawa originlocalhost, jadi dihitung sebagai kiriman uji. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan darilocalhost. 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
| 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
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.
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.
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.
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.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.