Daftar tunggu untuk SaaS baru

Daftar tunggu untuk SaaS baru6 menit baca

Membuat daftar tunggu di Nuxt

Daftar tunggu Nuxt di landing page yang di-prerender: satu route Nitro menerima $fetch dan kiriman formulir biasa, memeriksanya dengan Zod, dan me-redirect tanpa JavaScript.

Preet SutharPendiri, Ikiform

Prerender landing page dengan routeRules dan kirim formulir ke server route Nitro yang memvalidasi email dan peran dengan Zod lalu meneruskannya ke backend formulir Anda dengan secret key dari runtimeConfig. Route menjawab JSON untuk $fetch dan redirect 303 untuk kiriman formulir biasa, sehingga pendaftaran berfungsi sebelum halaman di-hydrate.

Panduan utama punya tab Nuxt: server route, runtimeConfig, dan komponen yang mengirim dengan $fetch. Formulir itu memerlukan JavaScript, hanya mengirim email, dan menyajikan halaman di setiap request. Versi ini melakukan prerender landing page, mengajukan satu pertanyaan pengelompokan, dan tetap berfungsi saat bundle belum dimuat.

Apa yang berbeda di Nuxt

Nuxt merender di server secara default, dan Nitro menyajikan folder server/ di samping halaman Anda. Tiga detail menentukan bentuk daftar tunggu:

DetailDampaknya pada daftar tunggu
routeRules dengan prerender: trueLanding page dibangun sekali sebagai HTML. Server route tetap berjalan per request
readBody juga mem-parse body urlencoded<form> biasa bisa mengirim ke route yang sama dengan $fetch, begitu pula formulir di situs orang lain
Halaman yang di-prerender tidak punya query string saat build?ref= dan ?problem= harus dibaca setelah halaman di-mount, bukan saat render

Baris kedua punya dua sisi. Itulah yang membuat jalur tanpa JavaScript berfungsi, dan itulah sebabnya route memeriksa header Sec-Fetch-Site sebelum melakukan apa pun: jika tidak, formulir di situs lain bisa mengirim pendaftaran memakai key Anda.

Satu route Nitro menjawab $fetch dengan JSON dan kiriman biasa dengan redirect/yangdi-prerenderonMountedmembaca?ref=server/api/waitlist.post.tsceksame-site,ZodIkiformsecretkey,menyimpannya{joined:true}$fetch,halamansama303/thanksatau/?problem=emailhalamandi-prerendermenampilkanhasilPOSTJSONcontent-type jsonurlencodedSatu route Nitro menjawab $fetch dengan JSON dan kiriman biasa dengan redirect/ yangdi-prerenderonMounted membaca?ref=server/api/waitlist.post.tscek same-site, ZodIkiformsecret key,menyimpannya{ joined: true }$fetch, halaman sama303/thanksatau/?problem=emailhalamandi-prerendermenampilkanhasilPOSTJSONcontent-type jsonurlencoded
  1. Landing page adalah HTML statis. Setelah di-mount, komponen menyalin ?ref= atau ?utm_source= ke nilai sumbernya.
  2. Formulir mengirim ke server route: sebagai JSON lewat $fetch setelah Vue selesai hydrate, atau urlencoded sebagai kiriman biasa sebelum itu.
  3. Route menolak kiriman lintas situs, memvalidasi body dengan Zod, dan meneruskan pendaftaran ke Ikiform dengan secret key.
  4. Kiriman JSON mendapat { joined: true }, atau error yang pesannya ditampilkan komponen di bawah formulir.
  5. Kiriman biasa mendapat 303 ke /thanks, atau kembali ke / dengan kode masalah yang diubah halaman menjadi pesan setelah di-mount.
Content type kiriman menentukan jawabannya. Kedua halaman tempat browser mendarat di-prerender.

Siapkan proyek

Buat formulir dan secret key seperti di panduan Nuxt di dokumentasi. File di halaman ini, dalam layout app/ Nuxt 4:

Proyek
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});

ikiformSecretKey yang kosong di runtimeConfig diisi saat runtime dari NUXT_IKIFORM_SECRET_KEY. Key di luar public hanya ada di server. Tambahkan Zod dengan npm install zod.

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

Formulir ini mengirim email, role, dan ref. Jika Anda menambahkan definisi field di tab Fields formulir, cantumkan ketiganya, dengan nilai peran yang sama seperti di select:

Field
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}

Bangun

Server route-nya

server/api/waitlist.post.ts
import { z } from "zod";

const Signup = z.object({
  email: z.email(),
  role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
  ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
  _gotcha: z.string().catch(""),
});

const MESSAGES = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

type Problem = keyof typeof MESSAGES;

export default defineEventHandler(async (event) => {
  if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const plain = !getRequestHeader(event, "content-type")?.includes("application/json");

  const answer = (problem: Problem | null) => {
    if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
    if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
    return { joined: true };
  };

  const parsed = Signup.safeParse(await readBody(event));
  if (!parsed.success) return answer("email");

  const config = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(parsed.data),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return answer("failed");
  if (response.status === 429) return answer("busy");
  const result = await response.json().catch(() => null);
  if (result?.ok) return answer(null);
  return answer(result?.error?.fields?.email ? "email" : "failed");
});
  • Akhiran .post.ts membatasi route hanya untuk POST. Metode lain mendapat 405.
  • Sec-Fetch-Site bernilai same-origin untuk halaman Anda sendiri dan cross-site untuk formulir di tempat lain. Browser yang mengaturnya dan skrip halaman tidak bisa mengubahnya.
  • .catch() Zod mengubah role atau ref yang salah menjadi nilai aman alih-alih menggagalkan pendaftaran. Hanya email yang salah yang layak ditampilkan sebagai error.
  • Header Origin browser diteruskan karena Ikiform menghitung kiriman uji gratis dari localhost berdasarkan header itu, dan fetch milik route tidak mengirimnya.
  • useRuntimeConfig(event) membaca key per request, jadi nilai yang diatur di host setelah build tetap terbaca.
  • Redirect membawa kode masalah singkat, tidak pernah pesannya, jadi tautan yang direkayasa tidak bisa menulis teks ke halaman Anda.

Komponen formulir

app/components/WaitlistForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");

onMounted(() => {
  const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
  if (tag) source.value = tag;
  const problem = String(route.query.problem ?? "");
  if (MESSAGES[problem]) error.value = MESSAGES[problem];
});

async function join() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/waitlist", {
      method: "POST",
      body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
    });
    status.value = "joined";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
  <form v-else method="post" action="/api/waitlist" @submit.prevent="join">
    <label for="email">Email</label>
    <input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
    <label for="role">What do you do?</label>
    <select id="role" v-model="role" name="role">
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <input type="hidden" name="ref" :value="source" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
  </form>
</template>
  • Formulir mempertahankan method dan action. Sebelum hydration, browser mengirimnya sebagai urlencoded; setelahnya, @submit.prevent menyerahkannya ke $fetch, yang mengirim JSON.
  • onMounted membaca query. Membacanya saat render akan memberi HTML hasil build nilai kosong dan browser nilai berbeda, yaitu hydration mismatch.
  • Peran kosong dikirim sebagai undefined, yang dihilangkan $fetch dari body JSON.
  • Error $fetch membawa pesan route di data.message, jadi komponen menampilkan kata-kata dari server.

Gunakan <WaitlistForm /> di app/pages/index.vue, dan tulis app/pages/thanks.vue dengan kalimat terima kasih. Honeypot diteruskan tanpa diubah, jadi bot yang mengisinya disimpan sebagai spam. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan Spam check tidak menambah apa pun di sini.

Catatan deploy

Build dengan nuxt build. nuxt generate melakukan prerender setiap halaman sebagai situs statis tanpa server, jadi /api/waitlist tidak akan pernah berjalan. routeRules di atas memberi Anda halaman statis dan route live dari satu build.

HostSetupSecret-nya
VercelTanpa konfigurasi. Vercel mendeteksi Nitro saat imporNUXT_IKIFORM_SECRET_KEY di Environment Variables
NetlifyTanpa konfigurasi. Nuxt mendeteksi build NetlifyNUXT_IKIFORM_SECRET_KEY di Environment variables
Cloudflare PagesTanpa konfigurasi dengan integrasi Git. Untuk upload langsung, build dengan --preset=cloudflare_pagesNUXT_IKIFORM_SECRET_KEY sebagai variabel terenkripsi di pengaturan proyek

Pastikan berfungsi

  1. Jalankan nuxt dev, buka http://localhost:3000/?ref=test, pilih peran, dan mendaftar. Kalimat terima kasih menggantikan formulir.
  2. Buka Submissions di dashboard. Pendaftaran memiliki ref bernilai test dan peran yang Anda pilih.
  3. Matikan JavaScript dan mendaftar tanpa peran. Anda mendarat di /thanks, dan kiriman tidak memiliki role.
  4. Dengan JavaScript mati, kirim a@b. Anda mendarat di /?problem=email, dan pesannya muncul setelah Anda menyalakan JavaScript lagi dan reload.
  5. Kirim dengan honeypot terisi, _gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
  6. Kiriman dari nuxt dev membawa origin localhost, jadi dihitung sebagai kiriman uji. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat harga.

Jebakan

MasalahYang harus dilakukan
404 pada /api/waitlist di productionSitus dibangun dengan nuxt generate, yang tidak menyertakan server. Build dengan nuxt build dan pertahankan prerender di routeRules.
Peringatan hydration mismatch pada input tersembunyi refQuery dibaca saat render. Baca di onMounted, seperti di atas; HTML yang di-prerender tidak pernah punya query string.
Setiap kiriman menjawab failedKey-nya kosong. Variabel harus bernama NUXT_IKIFORM_SECRET_KEY agar mengisi runtimeConfig.ikiformSecretKey.
422 dengan unknown_field untuk role atau refDefinisi field tidak mencantumkannya. Tambahkan keduanya, seperti pada definisi di atas.
402 plan_requiredWorkspace tidak punya paket dan kiriman berasal dari situs live. Kiriman ditahan, tidak hilang. Pilih paket atau beli paket kiriman di Settings โ€บ Plan and usage, dan kiriman yang ditahan akan masuk.

Pertanyaan

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