# Buat formulir pendaftaran newsletter di Next.js

> Formulir newsletter di footer Next.js: server action dengan pemeriksaan persetujuan dan Turnstile, mengirim ke Ikiform, plus route handler yang menambahkan pembaca ke Kit.

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

Taruh formulir klien di footer root layout dan arahkan ke server action. Action memeriksa kotak persetujuan dan token Cloudflare Turnstile, lalu meneruskan pendaftaran ke backend formulir Anda dengan secret key. Route handler terpisah menerima webhook bertanda tangan dari backend dan menambahkan pembaca ke alat email Anda, jadi spam tidak pernah sampai ke daftar Anda.

[Tab Next.js di panduan utama](https://ikiform.com/id/guides/newsletter-signup.md#form) punya server action dan komponen formulir. Halaman ini membangun versi lengkap untuk situs sungguhan: satu formulir di footer setiap halaman, pemeriksaan manusia yang tetap bekerja saat secret key melewati pemeriksaan Ikiform, dan route handler yang meneruskan pendaftaran yang terbukti valid ke Kit. Penyiapan produk dan key ada di [panduan dokumentasi Next.js](https://ikiform.com/id/docs/guides/nextjs.md).

## Apa yang diubah formulir footer di Next.js

Formulir footer ada di root layout, jadi dirender di setiap halaman, termasuk halaman yang dibangun Next.js sekali saat deploy. Ada tiga konsekuensinya:

| Persoalan | Yang terjadi | Yang dilakukan build ini |
| --- | --- | --- |
| Halaman statis | Formulir tidak boleh membaca cookie atau header saat render, atau setiap halaman menjadi dinamis | Formulir tidak membaca apa pun saat render. Hanya action yang menyentuh request |
| Pemeriksaan manusia | Kiriman dengan secret key melewati pemeriksaan Turnstile Ikiform | Menjalankan pemeriksaan Turnstile sendiri di dalam action |
| Siapa yang bisa memanggilnya | Server action adalah endpoint POST publik, bisa dijangkau tanpa formulir Anda | Memvalidasi persetujuan dan token sebelum apa pun dikirim |

Diagram: Bagaimana pendaftaran dari footer Next.js sampai ke Ikiform lalu ke Kit

1. Pembaca mengisi formulir footer. Formulir mengirim ke server action di halaman mana pun yang sedang dibaca.
2. Action memeriksa kotak persetujuan, lalu memverifikasi token Turnstile ke Cloudflare.
3. Jika salah satu pemeriksaan gagal, formulir kembali dengan pesan dan email yang diketik. Tidak ada yang dikirim.
4. Jika tidak, action mengirim pendaftaran ke Ikiform dengan secret key dan Origin browser.
5. Jawaban Ikiform menjadi state action, dan formulir diganti dengan baris periksa-inbox.
6. Secara terpisah, Ikiform mengirim webhook bertanda tangan untuk setiap pendaftaran yang bukan spam. Route handler memverifikasinya dan menambahkan pembaca ke formulir Kit, yang incentive email-nya adalah konfirmasi Kit. Uji sekali dengan alamat milik Anda; [panduan Kit](https://ikiform.com/id/guides/newsletter-signup/kit.md) menjelaskan alasannya.

Action tidak pernah memanggil Kit sendiri. Ikiform menjawab spam dengan sukses yang sama seperti pendaftaran asli, jadi hanya webhook yang tahu pendaftaran mana yang asli.

## Setup proyek

`Struktur proyek`:

```text
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local
```

Buat secret key di **API keys** dengan scope `submissions:write`. Buat widget Turnstile di dashboard Cloudflare untuk domain Anda dan `localhost`. Hanya site key yang boleh memakai awalan `NEXT_PUBLIC_`; nilai lainnya harus tetap khusus server.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

Tempel [definisi field dari pilar](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir, agar Ikiform menegakkan kotak `consent` yang wajib apa pun yang sampai kepadanya.

## Bangun

### Pemeriksaan Turnstile

Endpoint siteverify Cloudflare menerima secret, token, dan, opsional, IP pengunjung. Token berlaku 300 detik dan hanya bisa diperiksa sekali.

`lib/turnstile.ts`:

```ts
import "server-only";

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export async function passesTurnstile(token: string, visitor?: string) {
  if (!token) return false;
  const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
  if (visitor) body.set("remoteip", visitor);
  const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
  const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
  return outcome?.success === true;
}
```

### Server action

`app/newsletter/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";

export interface SignupState {
  status: "idle" | "subscribed" | "error";
  field?: "email" | "consent" | "check";
  message: string;
  email: string;
  firstName: string;
}

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

export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "").trim();
  const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
  const kept = { email, firstName };

  if (formData.get("consent") !== "true") {
    return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
  }

  const request = await headers();
  const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
    return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
  }

  const source = String(formData.get("source") ?? "");
  let response: Response;
  try {
    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.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        first_name: firstName || undefined,
        consent: true,
        source: PLACEMENTS.includes(source) ? source : "footer",
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) {
    return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
  }
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
  return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}
```

- Persetujuan diperiksa lebih dulu karena tidak memakan biaya. Panggilan Turnstile menyusul kemudian.
- `source` dicocokkan dengan daftar tetap. Kiriman rekayasa tidak bisa mengisi laporan penempatan Anda dengan sampah.
- Baris `Origin` meneruskan origin browser, karena Ikiform mengenali uji `localhost` gratis dari header itu dan `fetch` milik server tidak mengirimnya.
- Setiap error mengembalikan email dan nama yang diketik. React mereset formulir setelah action, dan `defaultValue` mengembalikannya.
- Honeypot diteruskan tanpa diubah. Ikiform menyimpan yang terisi sebagai spam dan menjawab dengan sukses, jadi bot tidak belajar apa-apa.

> 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 dan status pending-nya

`app/newsletter/submit-button.tsx`:

```tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
```

`app/newsletter/newsletter-form.tsx`:

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";

declare global {
  interface Window {
    turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
  }
}

const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };

export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
  const [state, action] = useActionState(subscribe, initial);
  const id = useId();
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mountCheck() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
    });
  }

  useEffect(mountCheck, []);
  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  if (state.status === "subscribed") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
      <label htmlFor={`${id}-email`}>Email</label>
      <input
        id={`${id}-email`}
        name="email"
        type="email"
        required
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={state.field === "email"}
      />
      <label htmlFor={`${id}-name`}>First name (optional)</label>
      <input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={state.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 />
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <SubmitButton />
    </form>
  );
}
```

- `useFormStatus` harus dibaca di child dari `<form>`, itulah sebabnya tombol menjadi komponen tersendiri.
- `useId` menjaga ID input tetap unik saat formulir footer dan formulir di halaman `/newsletter` tampil bersamaan.
- Script Turnstile dimuat dengan `?render=explicit`. `render` menaruh widget ke dalam formulir, yang kemudian mengirim input tersembunyi `cf-turnstile-response`. Rendering eksplisit juga bekerja untuk formulir yang di-mount setelah navigasi sisi klien.
- Token hanya bisa diperiksa sekali, jadi widget direset setelah setiap error. Tanpa reset, percobaan kedua selalu gagal.

### Taruh di layout

`app/layout.tsx`:

```tsx
import { NewsletterForm } from "./newsletter/newsletter-form";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        {children}
        <footer>
          <p>One email a month about what we shipped. No tracking pixels.</p>
          <NewsletterForm source="footer" />
        </footer>
      </body>
    </html>
  );
}
```

Gunakan `source="post"` di akhir artikel dan `source="newsletter-page"` di halaman khusus. `LayoutProps` adalah tipe yang sadar route yang dihasilkan Next.js untuk layout.

## Teruskan subscriber ke Kit

Route handler di bawah menerima webhook Ikiform. Handler membaca body sebagai teks lebih dulu, karena tanda tangan mencakup byte persisnya. Tambahkan URL-nya di **Webhooks**, pilih formulir newsletter, dan salin secret `whsec_`. [Panduan Kit](https://ikiform.com/id/guides/newsletter-signup/kit.md) membahas tag dan arti setiap jawaban Kit.

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

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function kit(path: string, body: Record<string, unknown>) {
  return fetch(`https://api.kit.com/v4${path}`, {
    method: "POST",
    headers: {
      "X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
}

export async function POST(request: Request) {
  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 created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok
    ? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
    : created;

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

- Jika Anda punya `proxy.ts`, dulu `middleware.ts`, yang me-redirect pengunjung tanpa sesi, keluarkan `/api/ikiform` dari `matcher`-nya. Jika tidak, setiap pengiriman mendapat redirect login Anda alih-alih handler.
- Dengan `trailingSlash: true` di `next.config`, Next.js me-redirect `/api/ikiform` ke `/api/ikiform/`. Daftarkan URL dengan garis miring.
- `503` dengan `Retry-After` membuat Ikiform menunggu dan mencoba lagi, dan `502` menaruh status Kit di log pengiriman.

## Rendering dan progressive enhancement

Tidak ada di formulir yang membaca request saat render, jadi layout tidak membuat halaman statis Anda menjadi dinamis. Action berjalan per request apa pun mode rendering halamannya. Karena action langsung dipasang ke `action`, React merender form post sungguhan, dan pembaca yang mengirim sebelum JavaScript termuat tetap mencapai action. Widget Turnstile memang butuh JavaScript, jadi dengan script mati pembaca mendapat pesan pemeriksaan manusia alih-alih pendaftaran. Jika kompromi itu keliru bagi pembaca Anda, hapus pemeriksaannya dan andalkan honeypot serta kotak wajib.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | Tambahkan keenam variabel di **Environment Variables** proyek. Arahkan webhook Ikiform ke domain produksi Anda; deployment preview bisa berada di balik Deployment Protection Vercel, yang menolak webhook. |
| Netlify | Netlify membangun Next.js dengan adapternya sendiri. Tambahkan variabel di pengaturan environment situs, dengan scope Functions, lalu deploy ulang. |
| Cloudflare | Jalankan Next.js di Workers melalui OpenNext. Atur variabel di pengaturan Worker, dan beri Worker domain kustom: Ikiform menolak URL webhook `workers.dev`. |

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Berkat `Origin` yang diteruskan, `next dev` di `http://localhost:3000` dihitung sebagai uji. Gunakan testing key Turnstile yang dipublikasikan Cloudflare di `.env.local` agar pemeriksaan selalu lolos di komputer Anda. Webhook tidak bisa menjangkau `localhost`; untuk mencoba route handler, ekspos lewat tunnel yang memberi URL HTTPS, atau gunakan **Send test** di dashboard setelah di-deploy.

## Pastikan berfungsi

1. Jalankan `next dev`, gulir ke footer, dan berlangganan dengan alamat milik Anda. Baris periksa-inbox menggantikan formulir.
2. Buka **Submissions**. Pendaftaran punya `consent` bernilai `true` dan `source` bernilai `footer`.
3. Hapus `required` dari checkbox di dev tools dan kirim tanpa dicentang. Pesan persetujuan muncul dan tidak ada yang disimpan.
4. Blokir `challenges.cloudflare.com` di dev tools lalu kirim. Pesan pemeriksaan manusia muncul dan tidak ada yang disimpan.
5. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengiriman menampilkan `succeeded` dengan jawaban dari server Anda.
6. Buka Kit. Subscriber ada di sana sebagai inactive, dan email konfirmasi Kit ada di inbox Anda.
7. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `402 plan_required` di setiap uji lokal | Kiriman sampai ke Ikiform tanpa origin `localhost`. Periksa baris `Origin` di action, dan buka aplikasi di `localhost`, bukan alamat jaringan Anda. |
| Pemeriksaan manusia gagal di setiap percobaan | Site key dan secret berasal dari widget berbeda, atau widget tidak disiapkan untuk hostname ini. Testing key hanya lolos dengan testing secret. |
| Setiap halaman statis menjadi dinamis setelah footer ditambahkan | Sesuatu di formulir atau layout memanggil `headers()` atau `cookies()` saat render. Baca request hanya di dalam action. |
| Failed to find Server Action, tepat setelah deploy | Sebuah tab masih menjalankan build sebelumnya, yang ID action-nya sudah berubah. Pembaca memuat ulang dan berfungsi lagi. |
| Pengiriman webhook menampilkan `307` atau `308` | Redirect proxy atau pengaturan trailing slash menangkap `/api/ikiform`. Keluarkan dari matcher atau daftarkan URL dengan garis miring. |
| 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

### Haruskah server action menambahkan subscriber ke alat email saya secara langsung?

Tidak. Ikiform menjawab spam dengan sukses yang sama seperti pendaftaran asli, jadi action tidak bisa membedakannya. Biarkan webhook Ikiform yang menggerakkan alat email: webhook hanya dikirim untuk pendaftaran yang bukan spam.

### Bisakah saya memakai pemeriksaan manusia bawaan Ikiform dari server action Next.js?

Tidak dengan secret key, yang melewatinya. Jalankan Turnstile sendiri di action, seperti di halaman ini, atau kirim dari browser dengan @ikiform/sdk, yang mempertahankan pemeriksaan Ikiform, allowed domains, dan batas per pengunjung.

### 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.
- [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.
- [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 Astro](https://ikiform.com/id/guides/newsletter-signup/astro.md): Formulir newsletter Astro yang tetap di halaman prerender: Astro Action memeriksa persetujuan, mengirim ke Ikiform, dan endpoint menambahkan pembaca ke MailerLite.
- [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.
- [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.
