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.
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:
| 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 |
- Pembaca mengisi formulir footer. Formulir mengirim ke server action di halaman mana pun yang sedang dibaca.
- Action memeriksa kotak persetujuan, lalu memverifikasi token Turnstile ke Cloudflare.
- Jika salah satu pemeriksaan gagal, formulir kembali dengan pesan dan email yang diketik. Tidak ada yang dikirim.
- Jika tidak, action mengirim pendaftaran ke Ikiform dengan secret key dan Origin browser.
- Jawaban Ikiform menjadi state action, dan formulir diganti dengan baris periksa-inbox.
- 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.
Setup proyek
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localBuat 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.
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=1234567Tempel 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.
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
"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.
sourcedicocokkan dengan daftar tetap. Kiriman rekayasa tidak bisa mengisi laporan penempatan Anda dengan sampah.- Baris
Originmeneruskan origin browser, karena Ikiform mengenali ujilocalhostgratis dari header itu danfetchmilik server tidak mengirimnya. - Setiap error mengembalikan email dan nama yang diketik. React mereset formulir setelah action, dan
defaultValuemengembalikannya. - Honeypot diteruskan tanpa diubah. Ikiform menyimpan yang terisi sebagai spam dan menjawab dengan sukses, jadi bot tidak belajar apa-apa.
IKIFORM_SECRET_KEY di environment variable khusus server.Formulir dan status pending-nya
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"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>
);
}useFormStatusharus dibaca di child dari<form>, itulah sebabnya tombol menjadi komponen tersendiri.useIdmenjaga ID input tetap unik saat formulir footer dan formulir di halaman/newslettertampil bersamaan.- Script Turnstile dimuat dengan
?render=explicit.rendermenaruh widget ke dalam formulir, yang kemudian mengirim input tersembunyicf-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
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.
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, dulumiddleware.ts, yang me-redirect pengunjung tanpa sesi, keluarkan/api/ikiformdarimatcher-nya. Jika tidak, setiap pengiriman mendapat redirect login Anda alih-alih handler. - Dengan
trailingSlash: truedinext.config, Next.js me-redirect/api/ikiformke/api/ikiform/. Daftarkan URL dengan garis miring. 503denganRetry-Aftermembuat Ikiform menunggu dan mencoba lagi, dan502menaruh 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
- Jalankan
next dev, gulir ke footer, dan berlangganan dengan alamat milik Anda. Baris periksa-inbox menggantikan formulir. - Buka Submissions. Pendaftaran punya
consentbernilaitruedansourcebernilaifooter. - Hapus
requireddari checkbox di dev tools dan kirim tanpa dicentang. Pesan persetujuan muncul dan tidak ada yang disimpan. - Blokir
challenges.cloudflare.comdi dev tools lalu kirim. Pesan pemeriksaan manusia muncul dan tidak ada yang disimpan. - Jika Anda menambahkan webhook, buka endpoint-nya di Webhooks. Pengiriman menampilkan
succeededdengan jawaban dari server Anda. - Buka Kit. Subscriber ada di sana sebagai inactive, dan email konfirmasi Kit ada di inbox Anda.
- 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
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.
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.
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.