Formulir pendaftaran newsletter

Formulir pendaftaran newsletter8 menit baca

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.

Preet SutharPendiri, Ikiform

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

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:

PersoalanYang terjadiYang dilakukan build ini
Halaman statisFormulir tidak boleh membaca cookie atau header saat render, atau setiap halaman menjadi dinamisFormulir tidak membaca apa pun saat render. Hanya action yang menyentuh request
Pemeriksaan manusiaKiriman dengan secret key melewati pemeriksaan Turnstile IkiformMenjalankan pemeriksaan Turnstile sendiri di dalam action
Siapa yang bisa memanggilnyaServer action adalah endpoint POST publik, bisa dijangkau tanpa formulir AndaMemvalidasi persetujuan dan token sebelum apa pun dikirim
Bagaimana pendaftaran dari footer Next.js sampai ke Ikiform lalu ke Kitselama pembaca menunggubeberapa detik kemudianformulirfooterrootlayout,setiaphalamanserveractionpersetujuan,laluTurnstileIkiformsecretkey,simpanpendaftaranactionstateperiksainboxAndastateerrortidakadayangdikirim,emailtetapIkiformhanyauntuknon-spam/api/ikiformmemverifikasitandatanganKitkirimkonfirmasiFormDataJSON + Originhasilcek gagalwebhook bertanda tanganv4 APIBagaimana pendaftaran dari footer Next.js sampai ke Ikiform lalu ke Kitselama pembaca menunggubeberapa detik kemudianformulir footerroot layout, setiaphalamanserveractionpersetujuan,laluTurnstileIkiformsecret key, simpanpendaftaranaction stateperiksa inbox Andastateerrortidak adayangdikirim,email tetapIkiformhanya untuknon-spam/api/ikiformmemverifikasi tandatanganKitkirim konfirmasiFormDataJSON + Originhasilwebhook bertanda tanganv4 APIcek gagal
  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 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
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
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 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
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
"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 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
"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
"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
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 membahas tag dan arti setiap jawaban Kit.

app/api/ikiform/route.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

HostYang harus dilakukan
VercelTambahkan 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.
NetlifyNetlify membangun Next.js dengan adapternya sendiri. Tambahkan variabel di pengaturan environment situs, dengan scope Functions, lalu deploy ulang.
CloudflareJalankan 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

MasalahYang harus dilakukan
402 plan_required di setiap uji lokalKiriman 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 percobaanSite 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 ditambahkanSesuatu di formulir atau layout memanggil headers() atau cookies() saat render. Baca request hanya di dalam action.
Failed to find Server Action, tepat setelah deploySebuah tab masih menjalankan build sebelumnya, yang ID action-nya sudah berubah. Pembaca memuat ulang dan berfungsi lagi.
Pengiriman webhook menampilkan 307 atau 308Redirect 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 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.