Formulir feedback untuk website atau aplikasi

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.

Preet SutharPendiri, Ikiform

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:

NilaiBerasal dariMengapa di sana
rating, commentFormulirnyaHanya pengguna yang mengetahuinya
user_id, emailCookie sesi, dibaca di actionBrowser tidak bisa memalsukan sesi yang ditandatangani
pageHeader Referer dari request actionServer action mengirim ke halaman yang merendernya, jadi header berisi URL lengkap
screenProp yang diteruskan halaman, seperti projects/[id]/settingsMengelompokkan 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.

Bagaimana jawaban feedback Next.js mendapatkan pengguna dan halaman di serverFeedbackFormrating,komentar,layarserveractionPOSTkehalamansaatinigetUser()membacacookiesesiIkiformsecretkey,menyimpannyaactionstateterimakasih,atauerrorstateerrorloginlagi,komentartetapFormDatacookies()+ pengguna, halamanhasiltanpa sesiBagaimana jawaban feedback Next.js mendapatkan pengguna dan halaman di serverFeedbackFormrating, komentar,layarserver actionPOST ke halaman saatinigetUser()membaca cookiesesiIkiformsecret key,menyimpannyaaction stateterima kasih, atauerrorstateerrorlogin lagi,komentartetapFormDatacookies()+ pengguna, halamanhasiltanpa sesi
  1. Pengguna memilih skor dan mungkin menulis komentar. Formulir mengirimnya bersama nama layar.
  2. Server action berjalan sebagai POST ke halaman tempat formulir berada, dengan cookie sesi dan halaman di Referer.
  3. getUser() membaca dan memeriksa cookie sesi. Tanpa sesi yang valid, tidak ada yang dikirim.
  4. Action menambahkan ID pengguna, email, dan alamat halaman, lalu mengirim ke Ikiform dengan secret key.
  5. Jawaban Ikiform menjadi state action: baris terima kasih, atau error dengan komentar yang masih tertulis.
Sesi dibaca saat formulir dikirim, bukan saat halaman dirender. Halaman di sekitar formulir bisa tetap statis.

Setup proyek

Empat file, salah satunya mungkin sudah Anda punya. Layout App Router-nya yang biasa:

Struktur proyek
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

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

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

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

Bagaimana layar ini bekerja untuk Anda?

Diatur oleh server Anda dari request.

Coba skor 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.

lib/user.ts
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

app/feedback/actions.ts
"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.
  • pageFrom hanya menyimpan origin dan path dari Referer. Query string di aplikasi sering berisi ID dan token yang tidak Anda inginkan di inbox feedback.
  • screen harus cocok dengan pola pendek mirip path. Selain itu disimpan sebagai unknown, sehingga kiriman rekayasa tidak bisa mengisi kolom dengan sampah.
  • Origin meneruskan origin browser. Di produksi nilainya domain Anda; di komputer Anda nilainya localhost, yang membuat uji lokal gratis, seperti dijelaskan di bawah.
  • AbortSignal.timeout mencegah tombol berputar selamanya saat jaringan lambat.

Formulirnya

app/feedback/feedback-form.tsx
"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.
  • pending dari useActionState menonaktifkan 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. Broken dan Great lebih bermakna daripada 1 dan 5, dan nilai yang disimpan tetap angka.

Taruh di sebuah layar

app/(app)/projects/[id]/settings/page.tsx
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

HostYang harus dilakukan
VercelTambahkan IKIFORM_SECRET_KEY di Environment Variables proyek untuk Production dan Preview. Action berjalan sebagai function tanpa penyiapan tambahan.
NetlifyNetlify membangun Next.js dengan integrasinya sendiri. Tambahkan key di environment variable situs dengan scope Functions, lalu deploy ulang.
CloudflareCloudflare 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

  1. Jalankan next dev, login, buka pengaturan sebuah proyek, dan kirim skor 2 dengan komentar.
  2. Buka Submissions. Jawabannya berisi user_id, email, screen bernilai projects/[id]/settings, dan page dengan ID proyek asli tanpa query string.
  3. Logout di tab lain, lalu kirim lagi. Formulir menampilkan pesan login dengan komentar Anda masih di kotaknya.
  4. Matikan JavaScript dan kirim sekali lagi. Halaman dimuat ulang dengan baris terima kasih.
  5. Balas email notifikasi. Balasannya menuju alamat pengguna yang login.

Hal yang sering menjebak

MasalahYang harus dilakukan
402 plan_required di setiap uji lokalKiriman sampai ke Ikiform tanpa origin localhost. Periksa baris Origin di action, dan buka aplikasi di localhost, bukan alamat jaringan komputer Anda.
page kosongReferrer-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 deploySebuah 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 IkiformKey tidak punya submissions:write, milik workspace lain, atau sudah dicabut. Buat yang baru.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

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