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

Terbit 2026-10-25 · https://ikiform.com/id/guides/newsletter-signup/react-router

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](https://ikiform.com/id/guides/newsletter-signup.md#form) 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](https://ikiform.com/id/docs/guides/server.md).

## 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:

Diagram: Apa yang dilihat pembaca tanpa JavaScript setelah mengirim formulir footer

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`:

```text
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`:

```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`:

```bash
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](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir.

## Bangun

### Route /newsletter

`app/routes/newsletter.tsx`:

```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](https://ikiform.com/id/docs/rate-limits.md) 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`:

```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`:

```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](https://ikiform.com/id/guides/newsletter-signup/mailerlite.md): relay itu mencari alamatnya lebih dulu, jadi pembaca terkonfirmasi yang mendaftar lagi mempertahankan statusnya.

`app/routes/api.ikiform.ts`:

```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](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md).

## 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

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

| 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

### Apakah useFetcher bekerja tanpa JavaScript?

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.

### Haruskah action newsletter berupa resource route?

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.

### Mengapa menambahkan subscriber ke MailerLite dari webhook alih-alih dari action?

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.

### Apakah Ikiform gratis?

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.

## Panduan lainnya

- [Formulir pendaftaran newsletter](https://ikiform.com/id/guides/newsletter-signup.md): Buat formulir pendaftaran newsletter dengan markup Anda sendiri, catat persetujuan, blokir pendaftaran bot, dan teruskan setiap subscriber ke Kit, MailerLite, beehiiv, atau alat apa pun.
- [MailerLite](https://ikiform.com/id/guides/newsletter-signup/mailerlite.md): Hubungkan formulir pendaftaran Anda sendiri ke MailerLite: relay menambahkan pembaca baru sebagai belum terkonfirmasi untuk double opt-in dan tidak pernah meminta subscriber mengonfirmasi dua kali.
- [Double opt-in](https://ikiform.com/id/guides/newsletter-signup/double-opt-in.md): Ikiform tidak mengirim email konfirmasi, jadi double opt-in berjalan di alat email Anda: relay menambahkan setiap pendaftaran sebagai belum terkonfirmasi dan alat itu mengirim tautannya.
- [Formulir newsletter TanStack Start](https://ikiform.com/id/guides/newsletter-signup/tanstack-start.md): Formulir newsletter TanStack Start: server function untuk pendaftaran di dalam halaman, server route sebagai cadangan tanpa JavaScript, dan pendaftaran asli dikirim ke beehiiv.
- [Situs statis](https://ikiform.com/id/guides/newsletter-signup/static-site.md): Tambahkan formulir pendaftaran newsletter ke situs statis Hugo, Jekyll, Eleventy, atau Astro, jauhkan API key alat email dari halaman, dan lakukan konfirmasi di alat itu.
- [Kit (ConvertKit)](https://ikiform.com/id/guides/newsletter-signup/kit.md): Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
