Formulir feedback untuk website atau aplikasi7 menit baca
Buat formulir feedback dalam aplikasi di Next.js
Formulir feedback dalam aplikasi Next.js: server action yang membaca sesi, mengambil halaman dari request, dan meneruskan rating beserta penggunanya.
Taruh formulir klien kecil dengan rating 1 sampai 5 di layar yang ingin Anda mintai feedback, dan arahkan ke server action. Action membaca pengguna yang login dari cookie sesi, mengambil halaman dari Referer request, dan meneruskan semuanya ke backend formulir Anda dengan secret key. Browser tidak pernah mengirim siapa penggunanya, sehingga tidak ada yang bisa mengirim feedback atas nama orang lain.
Tab Next.js di panduan utama adalah formulir feedback halaman untuk pengunjung mana pun. Halaman ini untuk bagian dalam aplikasi Anda, tempat orang sudah login. Di sana Anda sudah tahu siapa yang menulis dan di layar mana mereka berada, jadi formulir mengajukan dua pertanyaan dan server mengisi sisanya. Penyiapan produk, key, dan allowed domains ada di panduan dokumentasi Next.js.
Apa yang berubah saat pengguna sudah login
Formulir feedback publik menerima apa yang diberikan browser. Formulir di dalam aplikasi seharusnya tidak. Jika browser mengirim user_id, siapa pun bisa mengubahnya di dev tools dan mengirim feedback sebagai rekan kerja atau pelanggan. Setiap nilai sebaiknya berasal dari tempat yang bisa dipercaya untuknya:
| Nilai | Berasal dari | Mengapa di sana |
|---|---|---|
rating, comment | Formulirnya | Hanya pengguna yang mengetahuinya |
user_id, email | Cookie sesi, dibaca di action | Browser tidak bisa memalsukan sesi yang ditandatangani |
page | Header Referer dari request action | Server action mengirim ke halaman yang merendernya, jadi header berisi URL lengkap |
screen | Prop yang diteruskan halaman, seperti projects/[id]/settings | Mengelompokkan halaman pengaturan setiap proyek di bawah satu nama, sehingga rata-ratanya bermakna |
screen adalah satu-satunya nilai yang melewati browser. Nilai ini adalah label untuk pengelompokan, bukan izin, dan action memeriksa bentuknya sebelum disimpan.
- Pengguna memilih skor dan mungkin menulis komentar. Formulir mengirimnya bersama nama layar.
- Server action berjalan sebagai POST ke halaman tempat formulir berada, dengan cookie sesi dan halaman di Referer.
- getUser() membaca dan memeriksa cookie sesi. Tanpa sesi yang valid, tidak ada yang dikirim.
- Action menambahkan ID pengguna, email, dan alamat halaman, lalu mengirim ke Ikiform dengan secret key.
- Jawaban Ikiform menjadi state action: baris terima kasih, atau error dengan komentar yang masih tertulis.
Setup proyek
Empat file, salah satunya mungkin sudah Anda punya. Layout App Router-nya yang biasa:
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localBuat secret key di API keys dengan scope submissions:write dan taruh di .env.local. Jangan pernah memberinya awalan NEXT_PUBLIC_, yang akan mengirimkannya ke setiap browser.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesDi tab Fields formulir, tempel definisi ini. Pengguna melihat dua field pertama; server Anda mengatur empat lainnya. Karena ada field email, Reply di notifikasi langsung menuju ke pengguna.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
0 atau nama field yang tidak dikenal, dan preview menjawab dengan 422 yang sama dengan yang diterima action Anda.Bangun
Baca pengguna di satu tempat
Next.js menyarankan data access layer: satu fungsi khusus server yang membaca dan memeriksa sesi, dipakai oleh setiap action. cache dari React membuat panggilan berulang dalam satu request hanya membaca cookie sekali. decrypt adalah apa pun yang sudah dimiliki kode sesi Anda; dengan library auth, kembalikan user dari library itu di sini.
import "server-only";
import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";
export interface User {
id: string;
email: string;
}
export const getUser = cache(async (): Promise<User | null> => {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const session = await decrypt(token);
return session?.userId ? { id: session.userId, email: session.email } : null;
});Server action
"use server";
import { headers } from "next/headers";
import { getUser } from "@/lib/user";
export interface FeedbackState {
status: "idle" | "sent" | "error";
message: string;
comment?: string;
}
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
const user = await getUser();
if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { status: "error", message: "Pick a score from 1 to 5.", comment };
}
const request = await headers();
const screen = String(formData.get("screen") ?? "");
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: request.get("origin") ?? "",
},
body: JSON.stringify({
rating,
comment,
page: pageFrom(request.get("referer")),
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: user.id,
email: user.email,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
return {
status: "error",
message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
comment,
};
}- Pemeriksaan sesi dilakukan paling awal dan tidak melempar exception. Pengguna yang sesinya kedaluwarsa di tengah menulis komentar mendapat pesan dan tetap memegang komentarnya, bukan halaman error.
pageFromhanya menyimpan origin dan path dariReferer. Query string di aplikasi sering berisi ID dan token yang tidak Anda inginkan di inbox feedback.screenharus cocok dengan pola pendek mirip path. Selain itu disimpan sebagaiunknown, sehingga kiriman rekayasa tidak bisa mengisi kolom dengan sampah.Originmeneruskan origin browser. Di produksi nilainya domain Anda; di komputer Anda nilainyalocalhost, yang membuat uji lokal gratis, seperti dijelaskan di bawah.AbortSignal.timeoutmencegah tombol berputar selamanya saat jaringan lambat.
Formulirnya
"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
export function FeedbackForm({ screen }: { screen: string }) {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.status === "sent") return <p role="status">{state.message}</p>;
return (
<form action={action} aria-busy={pending}>
<fieldset>
<legend>How is this screen working for you?</legend>
{SCORES.map((label, index) => (
<label key={label}>
<input type="radio" name="rating" value={index + 1} required /> {label}
</label>
))}
</fieldset>
<label htmlFor="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} defaultValue={state.comment} />
<input type="hidden" name="screen" value={screen} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}- Action langsung dipasang ke
action. Next.js merendernya sebagai form post sungguhan, jadi pengguna yang mengklik sebelum JavaScript termuat tetap mengirim feedback-nya. pendingdariuseActionStatemenonaktifkan tombol selama action berjalan, yang mencegah pengiriman ganda.- React mereset formulir setelah action.
defaultValue={state.comment}mengembalikan komentar saat ada error, sehingga tidak ada yang mengetiknya dua kali. - Label menamai skornya.
BrokendanGreatlebih bermakna daripada1dan5, dan nilai yang disimpan tetap angka.
Taruh di sebuah layar
import { FeedbackForm } from "@/app/feedback/feedback-form";
export default function ProjectSettingsPage() {
return (
<main>
<h1>Project settings</h1>
<aside>
<FeedbackForm screen="projects/[id]/settings" />
</aside>
</main>
);
}Teruskan path folder route sebagai screen. Halaman pengaturan setiap proyek kemudian berbagi satu nama, dan alamat lengkap dengan ID aslinya tetap ada di page.
Honeypot, pemeriksaan manusia, dan batas
Input tersembunyi _gotcha diteruskan apa adanya. Ikiform menyimpan yang terisi sebagai spam. Pemeriksaan manusia tidak berjalan, karena kiriman dengan secret key melewatinya, dan itu tidak masalah di sini: action menolak siapa pun tanpa sesi, yang tidak dimiliki bot. Risiko yang tersisa adalah satu akun yang login mengirim terus-menerus. Rate limit Ikiform hanya melihat alamat server Anda, jadi pilih preset yang sesuai.
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.
Rendering dan caching
Formulir tidak membaca apa pun saat render. Formulir tidak memanggil cookies() atau headers(), jadi tidak membuat halaman menjadi dinamis, dan dengan Cache Components aktif ia bisa berada di static shell tanpa boundary <Suspense>. Hanya action yang menyentuh request, dan action selalu berjalan per request. Jika halaman sudah membaca sesi untuk keperluan lain, tidak ada yang berubah.
Deploy
| Host | Yang harus dilakukan |
|---|---|
| Vercel | Tambahkan IKIFORM_SECRET_KEY di Environment Variables proyek untuk Production dan Preview. Action berjalan sebagai function tanpa penyiapan tambahan. |
| Netlify | Netlify membangun Next.js dengan integrasinya sendiri. Tambahkan key di environment variable situs dengan scope Functions, lalu deploy ulang. |
| Cloudflare | Cloudflare menjalankan Next.js di Workers melalui integrasi OpenNext. Simpan key sebagai secret Worker, dan taruh di .dev.vars untuk preview lokal build Worker. |
Di balik proxy atau CDN yang mengubah host, Next.js menolak action yang Origin-nya tidak cocok. Daftarkan domain itu di experimental.serverActions.allowedOrigins di next.config.ts.
Uji gratis di localhost
Workspace tanpa paket menerima 50 kiriman uji per bulan, tetapi hanya dari localhost, yang dibaca Ikiform dari header Origin. fetch di sisi server tidak mengirim Origin sendiri, jadi tanpa baris yang meneruskannya, setiap uji lokal akan kembali dengan 402 plan_required dan ditahan. Dengan baris itu, next dev di http://localhost:3000 dihitung sebagai uji lokal, dan kiriman produksi membawa domain asli Anda.
Pastikan berfungsi
- Jalankan
next dev, login, buka pengaturan sebuah proyek, dan kirim skor 2 dengan komentar. - Buka Submissions. Jawabannya berisi
user_id,email,screenbernilaiprojects/[id]/settings, danpagedengan ID proyek asli tanpa query string. - Logout di tab lain, lalu kirim lagi. Formulir menampilkan pesan login dengan komentar Anda masih di kotaknya.
- Matikan JavaScript dan kirim sekali lagi. Halaman dimuat ulang dengan baris terima kasih.
- Balas email notifikasi. Balasannya menuju alamat pengguna yang login.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
402 plan_required di setiap uji lokal | Kiriman sampai ke Ikiform tanpa origin localhost. Periksa baris Origin di action, dan buka aplikasi di localhost, bukan alamat jaringan komputer Anda. |
page kosong | Referrer-Policy yang ketat seperti no-referrer menghapus header itu, bahkan pada request same-origin. Gunakan strict-origin-when-cross-origin, nilai default-nya. |
| Failed to find Server Action, tepat setelah deploy | Sebuah tab masih menjalankan build sebelumnya, yang ID action-nya sudah tidak ada. Minta pengguna memuat ulang, dan jaga NEXT_SERVER_ACTIONS_ENCRYPTION_KEY tetap sama di setiap instance saat Anda self-host. |
403 forbidden dari Ikiform | Key tidak punya submissions:write, milik workspace lain, atau sudah dicabut. Buat yang baru. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
Pertanyaan
Baca sesi di dalam server action, bukan di browser. Panggil helper sesi atau auth Anda di sana dan tambahkan ID serta email pengguna ke kiriman. Apa pun yang dikirim browser tentang identitas bisa diubah oleh pengguna.
Server action adalah POST ke halaman yang merender formulir, jadi header Referer-nya berisi URL halaman itu. Simpan origin dan path-nya dan buang query string-nya.
Tidak yang ini. Formulir tidak membaca cookie atau header saat dirender. Hanya action yang membaca sesi, dan action berjalan di setiap submit.
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.