Daftar tunggu untuk SaaS baru

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.

Preet SutharPendiri, Ikiform

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 kirimanRoute saat ini, kecuali Anda mengatur actionAction route /waitlist
Setelah submitNavigasi. URL dan history berubahTanpa navigasi. Pengunjung tetap di halaman
HasilnyaactionData dari route yang dijalankanfetcher.data, dibagi oleh setiap fetcher dengan key yang sama
Tanpa JavaScriptKiriman biasaKiriman 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.

Pendaftaran footer dari setiap halaman React Router melewati satu route action/pricingref = pricingfetcher.Formkey: waitlistaction/waitlistmeneruskan keIkiformfetcher.datahalaman sama,headerdiperbarui/blog/launchref =blog-launchformulirbiasaJavaScriptbelum dimuataction/waitlistkode yang samahalaman/waitlistactionDataPOSTPOSThalamanformuliractionhasilPendaftaran footer dari setiap halaman React Router melewati satu route action/pricingref = pricingfetcher.Formkey: waitlistaction /waitlistmeneruskan ke Ikiformfetcher.datahalaman sama, header diperbarui/blog/launchref = blog-launchformulir biasaJavaScript belum dimuataction /waitlistkode yang samahalaman /waitlistactionDataPOSTPOST
  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 membahas pembuatan formulir dan secret key. File di halaman ini:

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

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

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

Daftarkan route

app/routes.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
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
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
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
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.

HostYang perlu ditambahkanMembaca key
Vercel@vercel/react-router, dengan presets: [vercelPreset()] di react-router.config.tsprocess.env, diatur di Environment Variables
Netlify@netlify/vite-plugin-react-router, sebagai netlifyReactRouter() di plugin Viteprocess.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.

Jebakan

MasalahYang harus dilakukan
Pendaftaran di footer bernavigasi ke /waitlist meskipun JavaScript aktifFormulirnya 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 actionssr bernilai false di react-router.config.ts. Ubah ke true, atau daftar tunggu tidak punya server untuk berjalan.
ref setiap pendaftaran adalah waitlistKiriman berasal dari halaman /waitlist itu sendiri. Itu halaman fallback; tautan harus mengarah ke halaman Anda yang sebenarnya.
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.