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.
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:
| Detail | Dampaknya pada daftar tunggu |
|---|---|
routeRules dengan prerender: true | Landing 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.
- Landing page adalah HTML statis. Setelah di-mount, komponen menyalin ?ref= atau ?utm_source= ke nilai sumbernya.
- Formulir mengirim ke server route: sebagai JSON lewat $fetch setelah Vue selesai hydrate, atau urlencoded sebagai kiriman biasa sebelum itu.
- Route menolak kiriman lintas situs, memvalidasi body dengan Zod, dan meneruskan pendaftaran ke Ikiform dengan secret key.
- Kiriman JSON mendapat { joined: true }, atau error yang pesannya ditampilkan komponen di bawah formulir.
- Kiriman biasa mendapat 303 ke /thanks, atau kembali ke / dengan kode masalah yang diubah halaman menjadi pesan setelah di-mount.
Siapkan proyek
Buat formulir dan secret key seperti di panduan Nuxt di dokumentasi. File di halaman ini, dalam layout app/ Nuxt 4:
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport 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.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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.tsmembatasi route hanya untukPOST. Metode lain mendapat405. Sec-Fetch-Sitebernilaisame-originuntuk halaman Anda sendiri dancross-siteuntuk formulir di tempat lain. Browser yang mengaturnya dan skrip halaman tidak bisa mengubahnya..catch()Zod mengubahroleataurefyang salah menjadi nilai aman alih-alih menggagalkan pendaftaran. Hanya email yang salah yang layak ditampilkan sebagai error.- Header
Originbrowser diteruskan karena Ikiform menghitung kiriman uji gratis darilocalhostberdasarkan header itu, danfetchmilik 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
<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
methoddanaction. Sebelum hydration, browser mengirimnya sebagai urlencoded; setelahnya,@submit.preventmenyerahkannya ke$fetch, yang mengirim JSON. onMountedmembaca 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$fetchdari body JSON. - Error
$fetchmembawa pesan route didata.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.
| Host | Setup | Secret-nya |
|---|---|---|
| Vercel | Tanpa konfigurasi. Vercel mendeteksi Nitro saat impor | NUXT_IKIFORM_SECRET_KEY di Environment Variables |
| Netlify | Tanpa konfigurasi. Nuxt mendeteksi build Netlify | NUXT_IKIFORM_SECRET_KEY di Environment variables |
| Cloudflare Pages | Tanpa konfigurasi dengan integrasi Git. Untuk upload langsung, build dengan --preset=cloudflare_pages | NUXT_IKIFORM_SECRET_KEY sebagai variabel terenkripsi di pengaturan proyek |
Pastikan berfungsi
- Jalankan
nuxt dev, bukahttp://localhost:3000/?ref=test, pilih peran, dan mendaftar. Kalimat terima kasih menggantikan formulir. - Buka Submissions di dashboard. Pendaftaran memiliki
refbernilaitestdan peran yang Anda pilih. - Matikan JavaScript dan mendaftar tanpa peran. Anda mendarat di
/thanks, dan kiriman tidak memilikirole. - Dengan JavaScript mati, kirim
a@b. Anda mendarat di/?problem=email, dan pesannya muncul setelah Anda menyalakan JavaScript lagi dan reload. - Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Kiriman dari
nuxt devmembawa originlocalhost, jadi dihitung sebagai kiriman uji. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan darilocalhost. 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
| Masalah | Yang harus dilakukan |
|---|---|
404 pada /api/waitlist di production | Situs dibangun dengan nuxt generate, yang tidak menyertakan server. Build dengan nuxt build dan pertahankan prerender di routeRules. |
Peringatan hydration mismatch pada input tersembunyi ref | Query dibaca saat render. Baca di onMounted, seperti di atas; HTML yang di-prerender tidak pernah punya query string. |
Setiap kiriman menjawab failed | Key-nya kosong. Variabel harus bernama NUXT_IKIFORM_SECRET_KEY agar mengisi runtimeConfig.ikiformSecretKey. |
422 dengan unknown_field untuk role atau ref | Definisi field tidak mencantumkannya. Tambahkan keduanya, seperti pada definisi di atas. |
402 plan_required | Workspace 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
Bisa. Beri formulir method dan action yang mengarah ke server route. readBody Nitro mem-parse kiriman urlencoded, dan route bisa menjawabnya dengan redirect, sementara kiriman $fetch dari halaman yang sudah di-hydrate mendapat JSON.
Bisa. Prerender halaman dengan routeRules dan biarkan server route formulir tetap live, lalu build dengan nuxt build. Baca nilai query seperti ref setelah halaman di-mount.
Di key runtimeConfig privat, yang diisi dari environment variable NUXT_ di host. Key privat hanya bisa dibaca di server, lewat useRuntimeConfig.
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.