Formulir pendaftaran newsletter

Formulir pendaftaran newsletter7 menit baca

Buat formulir pendaftaran newsletter di React Router

Formulir newsletter di footer React Router: useFetcher mengirim ke route /newsletter yang juga bekerja tanpa JavaScript, dan resource route mengisi MailerLite.

Preet SutharPendiri, Ikiform

Taruh action di route /newsletter sungguhan dengan halamannya sendiri, dan kirim ke sana dari formulir footer yang dibuat dengan useFetcher. Dengan JavaScript, fetcher mengirim di latar belakang dan pembaca tetap di artikel; tanpa JavaScript, formulir yang sama menjadi POST biasa yang mendarat di halaman /newsletter dengan hasilnya. Action memeriksa persetujuan dan meneruskan pendaftaran ke backend formulir Anda, dan resource route mengubah webhook bertanda tangannya menjadi subscriber MailerLite.

Tab React Router di panduan utama mengirim ke resource route dengan useFetcher. Itu berfungsi setelah JavaScript termuat. Halaman ini memperbaiki apa yang terjadi sebelumnya, mencegah pendaftaran memuat ulang setiap loader di halaman, dan menambahkan route webhook untuk MailerLite. Penyiapan produk ada di panduan dokumentasi sisi server.

Mengapa action butuh halaman

Dokumentasi React Router menggambarkan fetcher.Form sebagai formulir HTML biasa sampai JavaScript termuat. Submit pada saat itu adalah POST dokumen ke URL action formulir, dan browser menampilkan apa pun yang dirender route itu. Resource route tidak merender apa-apa; ia mengembalikan datanya. Jadi pembaca yang mengklik terlalu cepat mendarat di JSON mentah. Beri action route dengan default component dan POST yang sama mendarat di sebuah halaman:

Apa yang dilihat pembaca tanpa JavaScript setelah mengirim formulir footeraction di resource routeaction di route UIformulirfooterfetcher.Form,belum hydrate/newsletteraction, tanpakomponenJSON mentah{"ok":true}formulirfooterfetcher.Form,belum hydrate/newsletteraction +komponen defaulthalaman hasilactionDatadirenderPOST dokumenresponsPOST dokumenrenderApa yang dilihat pembaca tanpa JavaScript setelah mengirim formulir footeraction di resource routeaction di route UIformulir footerfetcher.Form, belum hydrate/newsletteraction, tanpa komponenJSON mentah{"ok":true}formulir footerfetcher.Form, belum hydrate/newsletteraction + komponen defaulthalaman hasilactionData direnderPOST dokumenresponsPOST dokumenrender
  1. Sebelum hydration, fetcher.Form adalah formulir biasa, jadi browser mengirim ke /newsletter dan bernavigasi ke sana.
  2. Jika /newsletter adalah resource route, browser menampilkan JSON dari action sebagai halaman.
  3. Jika /newsletter punya default component, React Router menjalankan action, lalu merender halaman dengan hasilnya sebagai actionData.
  4. Setelah JavaScript termuat, kedua versi berperilaku sama: fetcher mengirim di latar belakang dan pembaca tetap di tempat.
Perbaikannya cukup satu default export. Action dan formulir footer tetap sama.

Setup proyek

Ini memakai framework mode dengan server rendering aktif, ssr: true di react-router.config.ts, yang merupakan default.

Struktur proyek
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789

Buat key Ikiform di API keys dengan scope submissions:write dan tempel definisi field dari pilar di tab Fields formulir.

Bangun

Route /newsletter

app/routes/newsletter.tsx
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  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: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
  • Error dikembalikan dengan data() dan status 4xx, bukan dilempar, jadi sampai ke formulir alih-alih ke error boundary.
  • Pemeriksaan persetujuan berjalan sebelum panggilan jaringan. Kotak yang tidak dicentang tidak memakan biaya.
  • Baris Origin meneruskan origin browser, karena Ikiform mengenali uji localhost gratis dari header itu dan fetch milik server tidak mengirimnya.
  • Default component merender formulir yang sama, diisi dengan actionData, jadi hasil tanpa JavaScript dan hasil di dalam halaman tampak serupa.
Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Formulir footer

app/components/newsletter-form.tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
      </label>
      <label>
        First name (optional)
        <input name="first_name" maxLength={80} autoComplete="given-name" />
      </label>
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={error?.field === "consent"} />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
  • fetcher.data berisi jawaban action setelah submit di latar belakang; initial berisi jawabannya setelah POST dokumen. Mana pun yang ada, itulah yang dipakai.
  • fetcher.state bernilai submitting lalu loading sebelum kembali ke idle, jadi tombol tetap nonaktif di keduanya.
  • key per penempatan memberi formulir footer dan formulir di halaman /newsletter state terpisah saat keduanya tampil di layar.

Mount tanpa memuat ulang data halaman

app/root.tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/newsletter") return false;
  return defaultShouldRevalidate;
}

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}

Setelah fetcher mengirim ke action, React Router secara default melakukan revalidasi loader dari setiap route yang cocok. Pendaftaran newsletter tidak mengubah apa pun yang dimuat loader itu, jadi shouldRevalidate di root melewati pekerjaan itu saat kiriman ditujukan ke /newsletter. Tambahkan ekspor yang sama ke route mana pun yang loader-nya mahal. Formulir ditaruh di komponen default App alih-alih Layout, karena Layout juga membungkus error boundary.

Teruskan subscriber ke MailerLite

Webhook datang dari luar aplikasi Anda, jadi yang ini adalah resource route: action tanpa komponen, mengembalikan objek Response biasa seperti yang disarankan dokumentasi React Router untuk pemanggil eksternal. Route membaca body sebagai teks lebih dulu karena tanda tangan mencakup byte persisnya. Route mengirim setiap pendaftaran sebagai unconfirmed, yang cocok untuk daftar baru. Jika daftar Anda sudah punya pembaca terkonfirmasi, gunakan relay di panduan MailerLite: relay itu mencari alamatnya lebih dulu, jadi pembaca terkonfirmasi yang mendaftar lagi mempertahankan statusnya.

app/routes/api.ikiform.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  let event;
  try {
    event = await verifyWebhook({
      payload: await request.text(),
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}

Tambahkan https://your-site.com/api/ikiform di Webhooks, pilih formulir newsletter, dan salin secret whsec_.

Honeypot dan pemeriksaan manusia

Input _gotcha diteruskan dan Ikiform menyimpan yang terisi sebagai spam, dengan jawaban sukses dan tanpa webhook, jadi MailerLite tidak pernah melihatnya. Secret key melewati pemeriksaan Turnstile Ikiform, dan setiap kiriman kini berasal dari IP server Anda. Jika bot lolos, verifikasi token Turnstile di action sebelum fetch, seperti yang ditunjukkan panduan Turnstile.

Rendering dan prerendering

Anda bisa melakukan prerender artikel dengan opsi prerender sambil mempertahankan ssr: true; server tetap menjalankan /newsletter dan route webhook on demand. Mode SPA, ssr: false, tidak bekerja di sini: dokumentasi React Router menyatakan action dilarang tanpa server runtime, dan build gagal jika ada route yang punya action. Situs React Router yang sepenuhnya statis sebaiknya mengirim dari browser dengan @ikiform/sdk.

Deploy

HostYang harus dilakukan
VercelDeploy tanpa konfigurasi. Vercel menyarankan menambahkan vercelPreset() dari @vercel/react-router/vite ke presets di react-router.config.ts. Atur variabel di Environment Variables.
NetlifyInstal @netlify/vite-plugin-react-router dan tambahkan ke plugin Vite; SSR berjalan sebagai Netlify Functions. Atur variabel di pengaturan environment situs.
CloudflareMulai dari template React Router untuk Workers. Baca secret dengan import { env } from "cloudflare:workers" alih-alih process.env; React Router 8 tidak lagi menaruhnya di context.cloudflare.env. Gunakan domain kustom untuk webhook; Ikiform menolak workers.dev.

Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost, dan Origin yang diteruskan membuat setiap pendaftaran dari dev server dihitung sebagai salah satunya. Uji jalur klik-terlalu-cepat dengan mematikan JavaScript di dev tools. Ikiform tidak akan mengirim webhook ke localhost; ekspos dev server lewat tunnel dengan URL HTTPS untuk menguji /api/ikiform.

Pastikan berfungsi

  1. Jalankan react-router dev, buka sebuah artikel, dan berlangganan dari footer. Baris periksa-inbox muncul dan URL tidak berubah.
  2. Buka tab network dan berlangganan lagi dari halaman baru. Ada satu request ke /newsletter dan tidak ada request loader setelahnya.
  3. Matikan JavaScript lalu berlangganan. Anda mendarat di /newsletter dengan baris yang sama, bukan di JSON.
  4. Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
  5. Jika Anda menambahkan webhook, buka endpoint-nya di Webhooks. Pengiriman menampilkan succeeded dengan jawaban dari server Anda.
  6. Buka MailerLite. Subscriber ada di grup sebagai unconfirmed, dan email konfirmasi ada di inbox Anda.

Hal yang sering menjebak

MasalahYang harus dilakukan
Build gagal dengan error tentang actionssr bernilai false. Action butuh server runtime; aktifkan SSR dan gunakan prerender untuk halaman statis.
Setiap pendaftaran memuat ulang data halamanLoader melakukan revalidasi setelah action apa pun. Tambahkan shouldRevalidate yang mengembalikan false saat formAction bernilai /newsletter.
Secret key bernilai undefined di CloudflareWorkers tidak mengisi process.env secara default. Baca secret dari env di cloudflare:workers di dalam action.
402 plan_required pada uji lokalKiriman tidak punya origin localhost. Periksa baris Origin di action, dan buka aplikasi di localhost.
Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datangKiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya.

Pertanyaan

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