Formulir feedback untuk website atau aplikasi

Formulir feedback untuk website atau aplikasi7 menit baca

Buat formulir feedback dalam aplikasi di TanStack Start

Formulir feedback dalam aplikasi TanStack Start: server function POST dengan middleware sesi, route id sebagai konteks, dan halaman dibaca dari header request.

Preet SutharPendiri, Ikiform

Tulis server function POST untuk feedback dan pasang function middleware yang membaca sesi dan menaruh pengguna di context. Function menambahkan pengguna itu, halaman dari Referer request, dan route id yang cocok ke rating, lalu meneruskannya ke backend formulir Anda dengan secret key. Mount formulir sekali di layout route untuk pengguna login dan formulir itu tahu sendiri sedang berada di layar mana.

Tab TanStack Start di panduan utama mengirim feedback halaman dari siapa pun, dengan halaman diatur di browser. Di dalam aplikasi dengan login Anda bisa lebih baik: server tahu siapa yang bertanya, dan router tahu layar mana yang terbuka. Penyiapan akun, key, dan allowed domains dibahas di panduan dokumentasi server.

Siapa menyediakan apa

Di Start, server function adalah endpoint HTTP di bawah /_serverFn/ yang dipanggil komponen Anda lewat klien yang dihasilkan. Apa pun di input-nya bisa ditulis ulang oleh siapa pun yang memanggilnya. Jadi input hanya membawa apa yang dipilih pengguna, dan semua hal tentang identitas berasal dari middleware yang berjalan di server sebelum handler.

NilaiSumberKepercayaan
rating, commentFormData dari formulirKata-kata pengguna sendiri
user_id, emailcontext.user, diatur oleh middleware sesiDibaca dari cookie tersegel di server
pagegetRequestHeader("referer") di dalam handlerBrowser memanggil function dari halaman yang terbuka, jadi header berisi URL-nya
screenrouteId dari kecocokan terdalam, seperti /_app/projects/$projectId/settingsLabel pengelompokan. Diperiksa terhadap pola sebelum disimpan
Bagaimana server function TanStack Start menambahkan pengguna dan halaman ke jawaban feedbackFeedbackFormFormData+routeIduserMiddlewaremembacacookiesesivalidatorskor,komentar,bentuklayarhandlertambahpenggunadanhalamanRefererIkiformsecretkey,menyimpannyahasilerrorformulirtetap,komentartersimpan/_serverFncontext.userdataJSONtanpa penggunaBagaimana server function TanStack Start menambahkan pengguna dan halaman ke jawaban feedbackFeedbackFormFormData + routeIduserMiddlewaremembaca cookie sesivalidatorskor, komentar, bentuklayarhandlertambah penggunadan halamanRefererIkiformsecret key,menyimpannyahasilerrorformulirtetap,komentartersimpan/_serverFncontext.userdataJSONtanpa pengguna
  1. Formulir mengumpulkan skor dan komentar, menambahkan route id saat ini, lalu memanggil server function.
  2. Di server, middleware membaca sesi dan meneruskan pengguna sebagai konteks.
  3. Validator mengubah FormData menjadi objek bertipe dan membuang nama layar yang bentuknya salah.
  4. Handler menolak jika pengguna tidak ada, lalu menambahkan pengguna dan halaman dari header Referer.
  5. Handler mengirim ke Ikiform dengan secret key dan mengembalikan sent, atau error yang ditampilkan formulir.
Pengguna berasal dari cookie di server. Tidak ada isi data formulir yang bisa mengubah asal sebuah jawaban.

Setup proyek

Struktur ini mengasumsikan routing berbasis file dengan layout _app tanpa path yang membungkus setiap layar untuk pengguna login. session.ts adalah helper iron-session dari panduan autentikasi Start; library sesi apa pun yang membaca cookie bekerja dengan cara yang sama. findUser di users.ts dan getCurrentUserFn di server/user.ts adalah pencarian pengguna milik Anda sendiri.

Struktur proyek
src/
  components/feedback-form.tsx
  lib/
    session.ts
    user-middleware.ts
    users.ts
  routes/
    _app.tsx
    _app/projects/$projectId/settings.tsx
  server/
    feedback.ts
    user.ts
.env

Start membaca variabel tanpa awalan dari process.env hanya di server. Apa pun dengan awalan VITE_ di-bundle untuk browser, jadi key tidak boleh memakainya. Baca di dalam handler, bukan di bagian atas modul.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup

Tempel definisi field di tab Fields formulir. Formulir menampilkan dua di antaranya; server function mengisi empat lainnya, dan field email membuat Reply di setiap notifikasi 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.

Kirim rating: 6 atau field yang tidak ada di definisi, dan preview menampilkan 422 yang akan diterima server function Anda.

Bangun

Middleware sesi

Function middleware membungkus setiap server function yang mencantumkannya. Apa pun yang masuk ke next({ context }) tiba dengan tipe di handler. Middleware meneruskan null alih-alih melempar exception, sehingga handler bisa menjawab dengan pesan yang ditampilkan formulir.

src/lib/user-middleware.ts
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";

export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const session = await getAppSession();
  const user = session.userId ? await findUser(session.userId) : null;
  return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});

Server function

src/server/feedback.ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";

export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };

const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const sendFeedback = createServerFn({ method: "POST" })
  .middleware([userMiddleware])
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    const screen = String(data.get("screen") ?? "");
    return {
      rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
      comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
      screen: ROUTE_ID.test(screen) ? screen : "unknown",
      trap: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data, context }): Promise<FeedbackResult> => {
    if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
    if (data.rating === null) return { status: "error", message: "Choose a score first." };

    const origin = getRequestHeader("origin");
    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 ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: data.rating,
          comment: data.comment,
          page: pageFrom(getRequestHeader("referer")),
          screen: data.screen,
          user_id: context.user.id,
          email: context.user.email,
          _gotcha: data.trap,
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
    }

    const result = await response.json().catch(() => null);
    if (result?.ok) return { status: "sent" };
    return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
  });
  • .validator menerima FormData mentah dan mengembalikan objek biasa. Skor yang buruk menjadi null alih-alih error yang dilempar, yang akan sampai ke klien sebagai kegagalan umum.
  • getRequestHeader membaca request yang masuk dari mana saja di handler, tanpa objek request yang diteruskan ke sana kemari.
  • Origin diteruskan dari panggilan browser. Nilai ini menentukan apakah Ikiform memperlakukan kiriman sebagai uji lokal, dijelaskan di bawah.
  • Hanya ID dan email yang masuk ke konteks, bukan seluruh record pengguna. Apa pun yang dikembalikan handler diserialisasi ke browser.

Formulirnya

src/components/feedback-form.tsx
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";

const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
  const [result, setResult] = useState<FeedbackResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("screen", screen);
    setPending(true);
    try {
      setResult(await send({ data }));
    } catch {
      setResult({ status: "error", message: "We lost the connection. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;

  return (
    <form method="post" onSubmit={onSubmit} 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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result?.status === "error" ? <p role="alert">{result.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
  • useMatches dengan select hanya mengembalikan routeId dari kecocokan terakhir, jadi komponen dirender ulang saat layar berubah, bukan di setiap pembaruan router.
  • onSubmit menghentikan kiriman native dan memanggil function. Tidak ada yang mereset formulir, jadi setelah error komentarnya masih ada di kotak.
  • pending menonaktifkan tombol selama panggilan berlangsung, yang mencegah pengiriman ganda.

Mount di layout route

src/routes/_app.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";

export const Route = createFileRoute("/_app")({
  beforeLoad: async () => {
    const user = await getCurrentUserFn();
    if (!user) throw redirect({ to: "/login" });
    return { user };
  },
  component: () => (
    <>
      <Outlet />
      <aside>
        <FeedbackForm />
      </aside>
    </>
  ),
});

beforeLoad menjauhkan pengguna yang logout dari layar-layar itu, tetapi bukan itu yang melindungi feedback: siapa pun bisa memanggil /_serverFn/ secara langsung, itulah sebabnya middleware memeriksa lagi. Route id menyertakan layout tanpa path, karena itu ada awalan /_app di screen. Hapus sebelum disimpan jika Anda lebih suka nama yang lebih pendek.

Honeypot, pemeriksaan manusia, dan batas

Input _gotcha melewati validator tanpa diubah, dan Ikiform mencatat yang terisi sebagai spam. Pemeriksaan manusianya dilewati untuk kiriman dengan secret key, dan itu tidak masalah di balik sesi: bot tidak punya sesi. Yang tersisa adalah akun yang login mengirim terus-menerus, dan Ikiform menghitung semuanya terhadap 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.

SSR, mode SPA, dan prerendering

Formulir bekerja sama baik dengan SSR penuh maupun dengan spa: { enabled: true } di opsi tanstackStart(). Di mode SPA, hanya root shell yang di-prerender dan layar dirender di browser, tetapi server function tetap butuh server yang berjalan: jika host menulis ulang setiap path ke shell, tambahkan /_serverFn/* ke path yang diteruskannya. Jangan prerender layar untuk pengguna login; tidak ada pengguna saat build.

Dokumentasi Start juga menyebut properti .url untuk memanggil server function dari formulir HTML biasa. Halaman ini tidak memakainya. Layar untuk pengguna login sudah ter-hydrate sebelum siapa pun mengetik komentar, dan kiriman tanpa script butuh function yang menjawab dengan redirect alih-alih data. Anggap formulir ini butuh JavaScript.

Deploy

HostSetup
VercelTambahkan nitro() dari nitro/vite ke plugin Vite, deploy, dan tambahkan IKIFORM_SECRET_KEY ke environment variable proyek.
NetlifyTambahkan netlify() dari @netlify/vite-plugin-tanstack-start dan atur key di environment variable situs.
CloudflareTambahkan cloudflare({ viteEnvironment: { name: "ssr" } }) dari @cloudflare/vite-plugin, aktifkan nodejs_compat, dan simpan key dengan wrangler secret put. Dengan compatibility date 2025-04-01 atau setelahnya, flag itu mengisi process.env dengan secret Anda.

Uji gratis dari localhost

Tanpa paket, workspace menerima 50 kiriman uji per bulan saat header Origin menyebut localhost. fetch milik server Anda tidak punya origin, jadi kiriman tanpa header yang diteruskan dijawab 402 plan_required dan ditahan. Dengan header itu, vite dev di http://localhost:3000 meneruskan origin browser dan setiap jawaban dihitung sebagai uji. Di produksi, baris yang sama mengirim domain aplikasi Anda.

Pastikan berfungsi

  1. Jalankan vite dev, login, buka pengaturan sebuah proyek, dan kirim nilai 2 dengan komentar.
  2. Di Submissions, jawabannya berisi user_id, email, screen bernilai route id, dan page dengan ID proyek asli.
  3. Logout di tab lain lalu kirim lagi. Formulir menampilkan pesan logout dan tetap menyimpan komentar.
  4. Panggil function dari konsol browser dengan ID pengguna palsu di data formulir. user_id yang disimpan tetap milik sesi.
  5. Balas email notifikasi. Balasannya menuju pengguna yang login.

Hal yang sering menjebak

MasalahYang harus dilakukan
Server function kehilangan pemeriksaan CSRF setelah Anda menambahkan src/start.tsTanpa src/start.ts, Start menambahkan middleware CSRF untuk server function dengan sendirinya. Begitu Anda mendefinisikannya, daftarkan createCsrfMiddleware di sana sendiri, difilter untuk server function.
402 plan_required pada uji lokalHeader Origin tidak sampai ke Ikiform. Periksa baris penerusannya, dan buka aplikasi di localhost, bukan alamat LAN.
IKIFORM_SECRET_KEY bernilai undefined di CloudflareWorkers mengisi process.env hanya jika nodejs_compat aktif dan compatibility date 2025-04-01 atau setelahnya. Periksa keduanya di wrangler.jsonc, atau baca key dari env di cloudflare:workers.
screen selalu unknownRoute id gagal memenuhi pola, sering karena karakter seperti { dari parameter opsional. Perluas polanya agar cocok dengan route Anda.
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.