Daftar tunggu untuk SaaS baru

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.

Preet SutharPendiri, Ikiform

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:

ItemKiriman browser ke IkiformServer action dengan secret key
Siapa yang dilihat IkiformIP setiap pengunjungIP server Anda, untuk setiap pengunjung
Rate limit per pengunjungMilik Ikiform, per IPAnda yang menambahkan, ditunjukkan di bawah
Domain yang diizinkan dan pemeriksaan manusiaDijalankanDilewati, karena key membuktikan kiriman itu milik Anda
Siapa yang bisa memanggilnyaSiapa saja, dari domain yang diizinkanSiapa 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.

Bagaimana pendaftaran bergerak melalui server action Next.jsformuliruseOptimistictampilkanterima kasihserveractioncek email,batasi perIPIkiformsecret key,simpanpendaftaranactionstatebergabung,atau errorstateerrortak adakiriman,formulirkembaliFormDataJSONhasilmelebihi batasBagaimana pendaftaran bergerak melalui server action Next.jsformuliruseOptimistictampilkan terima kasihserver actioncek email, batasi perIPIkiformsecret key, simpanpendaftaranaction statebergabung, atau errorstateerrortak adakiriman,formulirkembaliFormDataJSONhasilmelebihi batas
  1. Pengunjung menekan tombol. useOptimistic langsung mengganti formulir dengan kalimat terima kasih.
  2. Server action memeriksa email dan menghitung percobaan terbaru pengunjung berdasarkan IP.
  3. Jika masih dalam batas, action mengirim pendaftaran ke Ikiform dengan secret key.
  4. Hasilnya menjadi action state. Jika berhasil, terima kasih tetap tampil; jika error, formulir kembali dengan pesan dan email yang sudah diketik.
  5. Jika melebihi batas, tidak ada yang dikirim dan state error langsung kembali.
Kalimat optimistis hanya bertahan selama action berjalan. Action state yang menentukan apa yang tetap di layar.

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

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
Versi sisi server mengirim dengan secret key, jadi Ikiform melewati Allowed domains dan pemeriksaan manusianya. Setiap kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sekaligus. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_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.

lib/rate-limit.ts
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, baca cf-connecting-ip. Header yang bisa diatur sendiri oleh pengunjung sama sekali bukan batas.

Server action

app/waitlist/actions.ts
"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.timeout mencegah jaringan yang lambat menahan tombol dalam status pending selamanya.
  • 429 dari Ikiform dibaca terpisah, bersama Retry-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 defaultValue mengembalikan alamatnya.
  • Header Origin dari browser diteruskan karena Ikiform menghitung kiriman uji gratis berdasarkan header itu, sedangkan fetch milik server tidak mengirimnya.
  • submit() dari SDK tidak mengirim header Authorization, jadi tidak bisa melewati domain yang diizinkan. Gunakan fetch dengan key di sini.

Formulir, dengan terima kasih optimistis

app/waitlist/waitlist-form.tsx
"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>
  );
}
  • useOptimistic harus diatur di dalam action. join adalah action formulir, jadi React menjalankannya dalam transition dan kalimat optimistis bertahan sampai dispatch selesai.
  • Jika berhasil, action state menjadi joined dan kalimatnya tetap, kini dengan pesan akhir. Jika error, nilai optimistis hilang, dan formulir kembali dengan state.email serta pesannya.
  • aria-busy memberi tahu screen reader bahwa kalimat itu masih dikonfirmasi.
Membungkus action di dalam 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

app/waitlist/page.tsx
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

  1. Jalankan next dev, buka /waitlist?ref=test, lalu daftar. Kalimat terima kasih muncul sebelum request selesai.
  2. Buka Submissions di dashboard. Pendaftaran ada di sana dengan ref bernilai test.
  3. Kirim enam kali dalam satu menit. Yang keenam menampilkan pesan batas, dan hanya lima kiriman yang disimpan.
  4. Atur IKIFORM_SECRET_KEY ke nilai yang salah lalu kirim. Kalimat optimistis dibatalkan dan formulir menampilkan error dengan email Anda masih terisi.
  5. Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan Reply ditujukan ke pengirim jika formulir punya field email.
  6. Kirim dengan honeypot terisi, _gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

Jebakan

MasalahYang harus dilakukan
403 forbidden dari IkiformKey berasal dari workspace lain, sudah dicabut, atau tidak punya submissions:write. Buat secret key dengan scope itu.
429 rate_limitedSatu 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 sekaligusHeader 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 pesanAction melempar error alih-alih mengembalikan state. Jadikan setiap jalur kegagalan sebagai JoinState yang dikembalikan, seperti di contoh.
Peringatan bahwa optimistic update terjadi di luar transitionshowJoined dipanggil dari event handler. Panggil di dalam action formulir, seperti yang dilakukan join.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.