Formulir feedback untuk website atau aplikasi7 menit baca
Buat formulir feedback dalam aplikasi di Nuxt
Formulir feedback dalam aplikasi Nuxt: server route yang membaca sesi pengguna, mengambil halaman dari Referer, dan tetap bekerja saat JavaScript belum termuat.
Kirim formulir dua pertanyaan ke server route Nuxt di server/api/feedback.post.ts. Route membaca pengguna yang login dengan getUserSession dari nuxt-auth-utils, mengambil halaman dari Referer request, memvalidasi skor dengan Zod, dan meneruskan semuanya ke backend formulir Anda dengan secret key yang disimpan di runtimeConfig. Dengan JavaScript, formulir mengirim lewat $fetch dan tetap di tempat; tanpa JavaScript, route yang sama me-redirect kembali dengan hasil di URL.
Tab Nuxt di panduan utama membuat feedback halaman untuk siapa pun yang berkunjung. Di dalam aplikasi Nuxt dengan akun, tugasnya lebih sempit dan datanya lebih kaya: pengguna sudah login, server tahu siapa mereka, dan router tahu layar mana yang mereka buka. Halaman ini membangun versi itu. Key, allowed domains, dan penyiapan produk lainnya ada di panduan dokumentasi Nuxt.
Apa yang diubah aplikasi Nuxt dengan login
Di formulir publik, browser yang menentukan apa yang disimpan. Di sini, browser hanya menentukan skor dan komentar. Semua yang menyatakan siapa dan di mana ditambahkan oleh server route, tempat tidak ada yang bisa mengubahnya:
| Field | Diatur oleh | Cara |
|---|---|---|
rating, comment | Pengguna | Tombol radio dan kotak teks |
user_id, email | Server route | getUserSession(event) membaca cookie sesi yang tersegel |
page | Server route | Header Referer, yang dikirim browser pada kiriman same-origin dengan path lengkap |
screen | Komponen, diperiksa oleh route | useRoute().name, seperti projects-id-settings, dibuat Nuxt dari path file halaman |
Nama route adalah kunci pengelompokan gratis di Nuxt. Halaman pengaturan setiap proyek berbagi projects-id-settings, jadi Anda bisa menghitung rata-rata skor per layar sementara page menyimpan alamat persisnya.
- Setelah hydration, komponen menghentikan submit native dan mengirim field sebagai JSON dengan $fetch.
- Server route memeriksa Origin, membaca pengguna dari sesi, memvalidasi skor, dan menambahkan halaman dari Referer.
- Route meneruskan jawaban ke Ikiform dengan secret key dari runtimeConfig.
- Request JSON mendapat { sent: true } atau error dengan pesan, dan formulir diperbarui di tempat.
- Sebelum hydration, browser sendiri yang mengirim formulir. Route menjalankan pemeriksaan dan kiriman yang sama.
- Lalu route menjawab dengan 303 kembali ke halaman, dengan hasilnya di query string untuk ditampilkan komponen.
Setup proyek
Ini memakai struktur Nuxt 4, dengan kode aplikasi di app/ dan kode server di root. Instal nuxt-auth-utils dan zod. Jika aplikasi Anda sudah punya cara login lain, pertahankan dan baca pengguna dari sesi Anda sendiri di route.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDeklarasikan secret key di runtimeConfig dengan default kosong. Nuxt mengisinya saat runtime dari NUXT_IKIFORM_SECRET_KEY, dan karena key tidak berada di bawah public, key hanya ada di server.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringBuat key di API keys dengan scope submissions:write. nuxt-auth-utils menyegel cookie sesinya dengan NUXT_SESSION_PASSWORD, yang minimal 32 karakter; di pengembangan, ia menuliskannya untuk Anda saat pertama kali dijalankan. Beri tahu bentuk pengguna Anda, agar session.user.id punya tipe:
declare module "#auth-utils" {
interface User {
id: string;
email: string;
}
}
export {};Lalu tempel definisi di tab Fields formulir. Dua field pertama adalah formulirnya; server route menambahkan sisanya, dan field email membuat Reply di setiap notifikasi menuju ke pengguna.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
6 menampilkan 422 yang akan diterima route.Bangun
Server route
import { z } from "zod";
const Feedback = z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().trim().max(4000).default(""),
screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
_gotcha: z.string().default(""),
});
function pageFrom(referer: string | undefined) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export default defineEventHandler(async (event) => {
const own = getRequestURL(event, { xForwardedHost: true });
if (getRequestHeader(event, "origin") !== own.origin) {
throw createError({ status: 403, message: "Posts from other sites are refused." });
}
const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
const referer = getRequestHeader(event, "referer");
const back = (outcome: string) => {
const target = new URL(referer ?? "/", own.origin);
return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
};
const session = await getUserSession(event);
if (!session.user) {
if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
return back("signed-out");
}
const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
if (!parsed.success) {
if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
return back("invalid");
}
const { ikiformSecretKey } = useRuntimeConfig(event);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${ikiformSecretKey}`,
Origin: own.origin,
},
body: JSON.stringify({
...parsed.data,
page: pageFrom(referer),
user_id: session.user.id,
email: session.user.email,
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return wantsJson ? { sent: true } : back("sent");
if (wantsJson) {
throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
}
return back("failed");
});- Akhiran
.post.tsmembuat file hanya menjawabPOST. - Pemeriksaan Origin berjalan sebelum hal lain.
readBody, yang dipakaireadValidatedBody, juga mem-parsing body urlencoded, jadi tanpa pemeriksaan itu situs lain bisa mengirim formulir biasa ke route ini dengan cookie pengguna Anda terlampir. getUserSessiontidak melempar exception. Route sendiri yang memilih jawabannya:401untuk$fetch, redirect untuk kiriman biasa.requireUserSessionakan mengirim halaman401ke pengguna tanpa JavaScript.z.coerce.number()mengubah"4"dari urlencoded menjadi4..catch("unknown")menyimpanscreenyang salah bentuk sebagaiunknownalih-alih menolak seluruh jawaban.backhanya menyimpan path dariReferer, jadi redirect tidak pernah bisa keluar dari situs Anda.
Komponennya
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
"signed-out": "Your session ended. Sign in again, then send it.",
invalid: "Pick a score from 1 to 5.",
failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");
async function send(event: SubmitEvent) {
const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", { method: "POST", body });
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
status.value = "idle";
}
}
</script>
<template>
<template v-if="loggedIn">
<p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
<form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
<fieldset>
<legend>How is this screen working for you?</legend>
<label v-for="(label, index) in SCORES" :key="label">
<input type="radio" name="rating" :value="index + 1" required /> {{ label }}
</label>
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
<input type="hidden" name="screen" :value="screen" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
</form>
</template>
</template>methoddanactionadalah atribut sungguhan, jadi sebelum hydration browser mengirim formulir ke route. Begitu Vue berjalan,@submit.preventmengambil alih.- Body JSON memberi tahu route bahwa ia bisa menjawab dengan JSON. Body berasal dari
FormData, jadi honeypot danscreenikut terkirim tanpa kode tambahan. - Pada
$fetchyang gagal,data.messagedari error adalah pesan daricreateErrordi route. - Setelah kiriman biasa,
?feedback=di URL mengatur state awal, jadi ucapan terima kasih atau error tampil di halaman yang dimuat ulang. useUserSessionmenyembunyikan formulir dari pengunjung yang belum login. Ini hanya kemudahan, bukan pemeriksaan; route yang melakukan pemeriksaan.
Taruh <FeedbackForm /> di halaman mana pun di bagian aplikasi untuk pengguna login, seperti app/pages/projects/[id]/settings.vue. Nuxt meng-import-nya otomatis.
Honeypot, pemeriksaan manusia, dan batas
Input _gotcha sampai ke Ikiform bersama field lainnya, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia Ikiform dilewati untuk kiriman dengan secret key, dan di sini sesi yang menjalankan tugas itu: route menolak siapa pun yang belum login. Yang tersisa adalah akun yang login mengirim terus-menerus, yang hanya bisa dihitung Ikiform berdasarkan alamat server Anda.
Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.
Pilihan rendering
Formulir butuh server rendering untuk hasil tanpa JavaScript. Pada route dengan ssr: false di routeRules, halaman kosong sampai JavaScript berjalan, jadi kiriman biasa tidak pernah terjadi dan ?feedback= hanya dibaca di browser. Itu tidak masalah, hanya menghilangkan fallback-nya. Jangan taruh formulir di route prerender: true: HTML dibangun sekali, jadi useUserSession tidak melihat siapa pun dan formulir tidak muncul sampai hydration.
Deploy
| Host | Yang harus dilakukan |
|---|---|
| Vercel | Nitro mendeteksi Vercel, jadi tidak perlu preset. Tambahkan NUXT_IKIFORM_SECRET_KEY dan NUXT_SESSION_PASSWORD di Environment Variables lalu deploy ulang. |
| Netlify | Juga terdeteksi. Tambahkan kedua variabel di pengaturan environment situs dengan scope Functions; server route berjalan sebagai function. |
| Cloudflare | Build dengan preset cloudflare_module untuk Workers, atau hubungkan repo ke Pages, yang terdeteksi otomatis. Simpan kedua nilai sebagai secret, dan taruh di .dev.vars untuk wrangler dev. |
Di Cloudflare, nilai environment hanya ada selama request berjalan. Route membaca useRuntimeConfig(event) di dalam handler, memang di sanalah tempatnya.
Uji gratis di localhost
Tanpa paket, workspace menerima 50 kiriman uji per bulan dari localhost, dinilai dari header Origin. fetch dari route Nitro tidak punya browser di belakangnya dan tidak mengirim Origin kecuali Anda mengaturnya. Itulah sebabnya route meneruskan own.origin: di bawah nuxt dev nilainya http://localhost:3000 dan dihitung sebagai uji, dan di produksi nilainya domain Anda. Hilangkan itu dan setiap uji lokal kembali dengan 402 plan_required.
Pastikan berfungsi
- Jalankan
nuxt dev, login, buka halaman pengaturan sebuah proyek, dan kirim skor 2 dengan komentar. - Di Submissions, pastikan
user_iddanemailcocok dengan akun Anda,screenbernilaiprojects-id-settings, danpageberisi path asli. - Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan baris terima kasih dan
?feedback=sentdi alamat. - Kirim ke
/api/feedbackdengancurltanpaOrigin. Route menjawab403dan tidak ada yang sampai ke Ikiform. - Hapus cookie sesi dan kirim dari halaman yang terbuka. Formulir menampilkan pesan login.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
403 dari route Anda sendiri di produksi | Proxy mengubah host, sehingga getRequestURL tidak sesuai dengan Origin browser. Buat proxy mengirim X-Forwarded-Host, yang dibaca oleh xForwardedHost: true. |
screen bernilai unknown di setiap jawaban | Nama route dengan akhiran locale, seperti dari @nuxtjs/i18n, mengandung karakter yang ditolak pola. Perluas regex-nya, atau hapus akhiran itu di komponen. |
| Secret key kosong di produksi | Nama variabel harus NUXT_ ditambah key dalam upper snake case, NUXT_IKIFORM_SECRET_KEY, dan key itu harus ada di runtimeConfig. |
402 plan_required pada uji lokal | Kiriman keluar tanpa Origin. Pertahankan header Origin: own.origin dan buka aplikasi di localhost. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
Pertanyaan
Dengan nuxt-auth-utils, panggil getUserSession(event) atau requireUserSession(event) di dalam handler dan baca session.user. Tanpanya, atur event.context.user di server middleware dan baca nilai itu. Jangan pernah mengambil pengguna dari body request.
Bisa. Beri formulir method dan action sungguhan yang mengarah ke server route, dan buat route me-redirect kembali dengan 303 saat request-nya bukan JSON. Setelah hydration, cegat submit dan gunakan $fetch.
Deklarasikan key di runtimeConfig di nuxt.config, dan Nuxt menimpanya saat runtime dari environment variable bernama NUXT_ ditambah key dalam upper snake case. Key di luar public tetap di server.
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.