Daftar tunggu untuk SaaS baru6 menit baca
Cara membuat daftar tunggu Next.js dengan server action
Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
Tulis server action yang memeriksa email, menerapkan batas per pengunjung, dan meneruskan pendaftaran ke form backend Anda dengan secret key. Panggil lewat useActionState agar error kembali sebagai state, dan bungkus dengan useOptimistic agar kalimat terima kasih muncul begitu tombol ditekan dan dibatalkan jika kiriman gagal.
Panduan utama punya tab Next.js singkat: server action dan useActionState. Itu cukup untuk landing page yang sepi. Halaman ini melangkah lebih jauh, untuk daftar tunggu yang akan Anda bagikan secara luas: terima kasih instan, pesan error yang jujur, dan batas per pengunjung yang sebaliknya hilang pada kiriman dari sisi server.
Apa yang diubah oleh server action
Server action menjaga secret key di server Anda dan memungkinkan Anda memeriksa email sebelum apa pun keluar dari aplikasi Anda. Server action juga memindahkan setiap kiriman ke alamat IP server Anda, dan itu punya tiga efek:
| Item | Kiriman browser ke Ikiform | Server action dengan secret key |
|---|---|---|
| Siapa yang dilihat Ikiform | IP setiap pengunjung | IP server Anda, untuk setiap pengunjung |
| Rate limit per pengunjung | Milik Ikiform, per IP | Anda yang menambahkan, ditunjukkan di bawah |
| Domain yang diizinkan dan pemeriksaan manusia | Dijalankan | Dilewati, karena key membuktikan kiriman itu milik Anda |
| Siapa yang bisa memanggilnya | Siapa saja, dari domain yang diizinkan | Siapa saja yang bisa mengirim POST ke halaman Anda |
Baris terakhir itu penting. Next.js memperlakukan server action sebagai endpoint publik: siapa saja bisa memanggilnya dengan POST buatan, bukan hanya formulir Anda. Karena secret key melewati pemeriksaan origin Ikiform, action Anda menjadi satu-satunya gerbang, jadi action itu butuh batasnya sendiri.
- Pengunjung menekan tombol. useOptimistic langsung mengganti formulir dengan kalimat terima kasih.
- Server action memeriksa email dan menghitung percobaan terbaru pengunjung berdasarkan IP.
- Jika masih dalam batas, action mengirim pendaftaran ke Ikiform dengan secret key.
- Hasilnya menjadi action state. Jika berhasil, terima kasih tetap tampil; jika error, formulir kembali dengan pesan dan email yang sudah diketik.
- Jika melebihi batas, tidak ada yang dikirim dan state error langsung kembali.
Bangun
Secret key
Di dashboard, buka API keys, buat secret key dengan scope submissions:write, dan simpan sebagai variabel khusus server. Jangan pernah memberinya awalan NEXT_PUBLIC_.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY di environment variable khusus server.Batas per pengunjung
Lima pendaftaran per menit per IP sangat longgar untuk manusia dan tidak berguna untuk skrip. Penghitung ini tinggal di memori server. server-only membuat build gagal jika komponen klien mengimpornya.
import "server-only";
const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();
export function allow(key: string, now = Date.now()) {
const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
const allowed = recent.length < LIMIT;
if (allowed) recent.push(now);
if (hits.size > 10_000) hits.clear();
hits.set(key, recent);
return allowed;
}- Memori tidak dibagi antar-instance server. Di satu server Node atau container, hitungannya tepat. Di hosting serverless, setiap instance menyimpan hitungannya sendiri, jadi anggap ini sebagai polisi tidur dan pindahkan penghitung ke penyimpanan bersama seperti Redis saat trafik bertambah.
- Hanya percayai header IP yang diatur oleh host Anda. Vercel mengatur
x-forwarded-for; di belakang Cloudflare, bacacf-connecting-ip. Header yang bisa diatur sendiri oleh pengunjung sama sekali bukan batas.
Server action
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";
export interface JoinState {
status: "idle" | "joined" | "error";
message: string;
email?: string;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };
const incoming = await headers();
const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };
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: incoming.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't reach the list. Try again.", email };
}
if (response.status === 429) {
const wait = response.headers.get("Retry-After") ?? "60";
return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
return {
status: "error",
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}- Pemeriksaan yang murah berjalan lebih dulu. Email yang buruk tidak pernah memakan biaya request.
AbortSignal.timeoutmencegah jaringan yang lambat menahan tombol dalam status pending selamanya.429dari Ikiform dibaca terpisah, bersamaRetry-After-nya, karena dengan semua pengunjung di belakang satu IP, yang sibuk adalah seluruh formulir, bukan orang ini.- Setiap error mengembalikan email, agar input bisa menampilkannya lagi. React mereset formulir setelah action, dan
defaultValuemengembalikan alamatnya. - Header
Origindari browser diteruskan karena Ikiform menghitung kiriman uji gratis berdasarkan header itu, sedangkanfetchmilik server tidak mengirimnya. submit()dari SDK tidak mengirim headerAuthorization, jadi tidak bisa melewati domain yang diizinkan. Gunakanfetchdengan key di sini.
Formulir, dengan terima kasih optimistis
"use client";
import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { status: "idle", message: "" };
export function WaitlistForm({ source }: { source: string }) {
const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
const [shown, showJoined] = useOptimistic(
state,
(_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
);
function join(formData: FormData) {
showJoined(String(formData.get("email") ?? ""));
dispatch(formData);
}
if (shown.status === "joined") {
return (
<p role="status" aria-busy={pending}>
{shown.message}
</p>
);
}
return (
<form action={join}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
<input type="hidden" name="ref" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
<button disabled={pending}>Join the waitlist</button>
</form>
);
}useOptimisticharus diatur di dalam action.joinadalah action formulir, jadi React menjalankannya dalam transition dan kalimat optimistis bertahan sampaidispatchselesai.- Jika berhasil, action state menjadi
joineddan kalimatnya tetap, kini dengan pesan akhir. Jika error, nilai optimistis hilang, dan formulir kembali denganstate.emailserta pesannya. aria-busymemberi tahu screen reader bahwa kalimat itu masih dikonfirmasi.
join menjadikannya fungsi klien, jadi formulir ini butuh JavaScript. Jika kemampuan mengirim sebelum hidrasi lebih penting daripada terima kasih instan, berikan dispatch langsung ke action dan hapus useOptimistic; sisa kodenya tetap sama.Halamannya
import { WaitlistForm } from "./waitlist-form";
export default async function Page({ searchParams }: PageProps<"/waitlist">) {
const { ref } = await searchParams;
const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";
return (
<main>
<h1>Acme is almost here</h1>
<p>Leave your email and we'll send you an invite the day it opens.</p>
<WaitlistForm source={source} />
</main>
);
}Halaman membaca ?ref= dari URL dan memberikannya ke formulir, jadi link seperti /waitlist?ref=newsletter menandai setiap pendaftaran dengan sumbernya. PageProps adalah tipe yang sadar route yang dihasilkan Next.js; membaca searchParams membuat halaman dirender per request.
Pastikan berfungsi
- Jalankan
next dev, buka/waitlist?ref=test, lalu daftar. Kalimat terima kasih muncul sebelum request selesai. - Buka Submissions di dashboard. Pendaftaran ada di sana dengan
refbernilaitest. - Kirim enam kali dalam satu menit. Yang keenam menampilkan pesan batas, dan hanya lima kiriman yang disimpan.
- Atur
IKIFORM_SECRET_KEYke nilai yang salah lalu kirim. Kalimat optimistis dibatalkan dan formulir menampilkan error dengan email Anda masih terisi. - Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan Reply ditujukan ke pengirim jika formulir punya field
email. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
Jebakan
| Masalah | Yang harus dilakukan |
|---|---|
403 forbidden dari Ikiform | Key berasal dari workspace lain, sudah dicabut, atau tidak punya submissions:write. Buat secret key dengan scope itu. |
429 rate_limited | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai Retry-After, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
| Semua pengunjung terkena batas sekaligus | Header IP tidak ada, jadi semua orang dihitung sebagai unknown. Baca header yang diatur host Anda, seperti cf-connecting-ip di belakang Cloudflare. |
| Terima kasih berkedip lalu formulir kembali tanpa pesan | Action melempar error alih-alih mengembalikan state. Jadikan setiap jalur kegagalan sebagai JoinState yang dikembalikan, seperti di contoh. |
| Peringatan bahwa optimistic update terjadi di luar transition | showJoined dipanggil dari event handler. Panggil di dalam action formulir, seperti yang dilakukan join. |
Pertanyaan
Keduanya bisa. Kiriman dari browser mempertahankan batas per pengunjung, domain yang diizinkan, dan pemeriksaan manusia dari Ikiform. Server action menyembunyikan endpoint, memeriksa input lebih dulu, dan berfungsi sebelum hidrasi, tetapi Anda harus menambahkan batas per pengunjung sendiri karena setiap kiriman datang dari server Anda.
Bisa. Atur nilai optimistis dan panggil fungsi dispatch dari useActionState di dalam action yang sama, seperti prop action milik formulir. Nilai optimistis tampil selama action berjalan dan digantikan oleh action state saat selesai.
Tidak. Server action meneruskan setiap pendaftaran ke Ikiform, yang menyimpannya, menyaring spam, mengirimi Anda email, dan mengirim webhook. Aplikasi Anda tidak menyimpan state apa pun selain penghitung rate limit.
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.