Daftar tunggu untuk SaaS baru6 menit baca
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.
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 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:
| Item | <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.
- Layout merender formulir daftar tunggu di setiap halaman, dengan ref tersembunyi yang dibuat dari path halaman.
- Dengan JavaScript, fetcher.Form mengirim ke /waitlist tanpa meninggalkan halaman. Key fetcher dibagi, jadi catatan di header melihat hasil yang sama.
- Tanpa JavaScript, formulir yang sama mengirim ke /waitlist sebagai navigasi biasa.
- Apa pun jalurnya, action /waitlist memeriksa email dan meneruskan pendaftaran ke Ikiform dengan secret key.
- Fetcher membaca hasil dari fetcher.data. Kiriman biasa mendarat di halaman /waitlist, yang menampilkannya dari actionData.
Siapkan proyek
Panduan server membahas pembuatan formulir dan secret key. File di halaman ini:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envLetakkan 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY di environment variable khusus server.Action mengirim email dan ref. Jika formulir punya definisi field di tab Fields, keduanya harus dicantumkan:
{
"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
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
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
Originbrowser diteruskan karena Ikiform menghitung kiriman uji gratis darilocalhostberdasarkan header itu, danfetchmilik action tidak mengirimnya. refharus berupa huruf kecil, angka,-, atau_, hingga 40 karakter, sama denganmax_lengthdi definisi. Selain itu disimpan sebagaidirect.- Default export adalah fallback tanpa JavaScript. Ia merender formulir yang sama dengan hasil action.
Formulir bersama
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.JoinedNotedi header membacafetcher.datayang sama, jadi berubah begitu formulir footer berhasil.sourceFormengubah/blog/launchmenjadiblog-launchdan/menjadihome. Path diketahui di server, jadi ini juga berfungsi di halaman yang di-prerender.fetcher.statebernilaisubmittingselama kiriman berjalan, laluloadingselama 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:
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:
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
- Jalankan
react-router dev, bukahttp://localhost:5173/pricing, dan mendaftar di footer. Kalimat terima kasih menggantikan formulir, dan catatan di header ikut berubah. - Buka Submissions di dashboard. Pendaftaran memiliki
refbernilaipricing. - Matikan JavaScript dan mendaftar dari beranda. Anda mendarat di
/waitlistdengan kalimat terima kasih, dan pendaftaran memilikirefbernilaihome. - Kirim
a@bdari footer. Error muncul di bawah input dengan alamat yang masih terketik. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Kiriman dari
react-router 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 |
|---|---|
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
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.
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.
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.
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.