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.
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:
- Sebelum hydration, fetcher.Form adalah formulir biasa, jadi browser mengirim ke /newsletter dan bernavigasi ke sana.
- Jika /newsletter adalah resource route, browser menampilkan JSON dari action sebagai halaman.
- Jika /newsletter punya default component, React Router menjalankan action, lalu merender halaman dengan hasilnya sebagai actionData.
- Setelah JavaScript termuat, kedua versi berperilaku sama: fetcher mengirim di latar belakang dan pembaca tetap di tempat.
Setup proyek
Ini memakai framework mode dengan server rendering aktif, ssr: true di react-router.config.ts, yang merupakan default.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Buat key Ikiform di API keys dengan scope submissions:write dan tempel definisi field dari pilar di tab Fields formulir.
Bangun
Route /newsletter
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 status4xx, 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
Originmeneruskan origin browser, karena Ikiform mengenali ujilocalhostgratis dari header itu danfetchmilik server tidak mengirimnya. - Default component merender formulir yang sama, diisi dengan
actionData, jadi hasil tanpa JavaScript dan hasil di dalam halaman tampak serupa.
IKIFORM_SECRET_KEY di environment variable khusus server.Formulir footer
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.databerisi jawaban action setelah submit di latar belakang;initialberisi jawabannya setelah POST dokumen. Mana pun yang ada, itulah yang dipakai.fetcher.statebernilaisubmittinglaluloadingsebelum kembali keidle, jadi tombol tetap nonaktif di keduanya.keyper penempatan memberi formulir footer dan formulir di halaman/newsletterstate terpisah saat keduanya tampil di layar.
Mount tanpa memuat ulang data halaman
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.
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
| Host | Yang harus dilakukan |
|---|---|
| Vercel | Deploy tanpa konfigurasi. Vercel menyarankan menambahkan vercelPreset() dari @vercel/react-router/vite ke presets di react-router.config.ts. Atur variabel di Environment Variables. |
| Netlify | Instal @netlify/vite-plugin-react-router dan tambahkan ke plugin Vite; SSR berjalan sebagai Netlify Functions. Atur variabel di pengaturan environment situs. |
| Cloudflare | Mulai 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
- Jalankan
react-router dev, buka sebuah artikel, dan berlangganan dari footer. Baris periksa-inbox muncul dan URL tidak berubah. - Buka tab network dan berlangganan lagi dari halaman baru. Ada satu request ke
/newsletterdan tidak ada request loader setelahnya. - Matikan JavaScript lalu berlangganan. Anda mendarat di
/newsletterdengan baris yang sama, bukan di JSON. - Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- Jika Anda menambahkan webhook, buka endpoint-nya di Webhooks. Pengiriman menampilkan
succeededdengan jawaban dari server Anda. - Buka MailerLite. Subscriber ada di grup sebagai unconfirmed, dan email konfirmasi ada di inbox Anda.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
| Build gagal dengan error tentang action | ssr bernilai false. Action butuh server runtime; aktifkan SSR dan gunakan prerender untuk halaman statis. |
| Setiap pendaftaran memuat ulang data halaman | Loader melakukan revalidasi setelah action apa pun. Tambahkan shouldRevalidate yang mengembalikan false saat formAction bernilai /newsletter. |
Secret key bernilai undefined di Cloudflare | Workers tidak mengisi process.env secara default. Baca secret dari env di cloudflare:workers di dalam action. |
402 plan_required pada uji lokal | Kiriman 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 datang | Kiriman 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
Ya. fetcher.Form merender formulir HTML biasa, jadi sebelum hydration formulir mengirim dan bernavigasi ke URL action-nya. Jadikan URL itu route dengan komponen, atau pembaca melihat data mentah dari action.
Tidak untuk formulir yang dikirim orang. Gunakan route dengan default component agar kiriman tanpa JavaScript menampilkan halaman. Simpan resource route untuk webhook dan pemanggil lain yang bukan browser.
Dua alasan. Action mendapat jawaban sukses yang sama untuk spam maupun pendaftaran asli, jadi action akan ikut menambahkan bot. Dan error MailerLite di dalam action akan menampilkan kegagalan untuk pendaftaran yang sudah disimpan Ikiform, sedangkan pengiriman webhook yang gagal akan dicoba ulang.
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.