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.
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.
| Nilai | Sumber | Kepercayaan |
|---|---|---|
rating, comment | FormData dari formulir | Kata-kata pengguna sendiri |
user_id, email | context.user, diatur oleh middleware sesi | Dibaca dari cookie tersegel di server |
page | getRequestHeader("referer") di dalam handler | Browser memanggil function dari halaman yang terbuka, jadi header berisi URL-nya |
screen | routeId dari kecocokan terdalam, seperti /_app/projects/$projectId/settings | Label pengelompokan. Diperiksa terhadap pola sebelum disimpan |
- Formulir mengumpulkan skor dan komentar, menambahkan route id saat ini, lalu memanggil server function.
- Di server, middleware membaca sesi dan meneruskan pengguna sebagai konteks.
- Validator mengubah FormData menjadi objek bertipe dan membuang nama layar yang bentuknya salah.
- Handler menolak jika pengguna tidak ada, lalu menambahkan pengguna dan halaman dari header Referer.
- Handler mengirim ke Ikiform dengan secret key dan mengembalikan sent, atau error yang ditampilkan formulir.
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.
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
.envStart 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setupTempel 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.
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.
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
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." };
});.validatormenerimaFormDatamentah dan mengembalikan objek biasa. Skor yang buruk menjadinullalih-alih error yang dilempar, yang akan sampai ke klien sebagai kegagalan umum.getRequestHeadermembaca request yang masuk dari mana saja di handler, tanpa objek request yang diteruskan ke sana kemari.Originditeruskan 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
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>
);
}useMatchesdenganselecthanya mengembalikanrouteIddari kecocokan terakhir, jadi komponen dirender ulang saat layar berubah, bukan di setiap pembaruan router.onSubmitmenghentikan kiriman native dan memanggil function. Tidak ada yang mereset formulir, jadi setelah error komentarnya masih ada di kotak.pendingmenonaktifkan tombol selama panggilan berlangsung, yang mencegah pengiriman ganda.
Mount di layout route
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
| Host | Setup |
|---|---|
| Vercel | Tambahkan nitro() dari nitro/vite ke plugin Vite, deploy, dan tambahkan IKIFORM_SECRET_KEY ke environment variable proyek. |
| Netlify | Tambahkan netlify() dari @netlify/vite-plugin-tanstack-start dan atur key di environment variable situs. |
| Cloudflare | Tambahkan 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
- Jalankan
vite dev, login, buka pengaturan sebuah proyek, dan kirim nilai 2 dengan komentar. - Di Submissions, jawabannya berisi
user_id,email,screenbernilai route id, danpagedengan ID proyek asli. - Logout di tab lain lalu kirim lagi. Formulir menampilkan pesan logout dan tetap menyimpan komentar.
- Panggil function dari konsol browser dengan ID pengguna palsu di data formulir.
user_idyang disimpan tetap milik sesi. - Balas email notifikasi. Balasannya menuju pengguna yang login.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
Server function kehilangan pemeriksaan CSRF setelah Anda menambahkan src/start.ts | Tanpa 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 lokal | Header Origin tidak sampai ke Ikiform. Periksa baris penerusannya, dan buka aplikasi di localhost, bukan alamat LAN. |
IKIFORM_SECRET_KEY bernilai undefined di Cloudflare | Workers 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 unknown | Route id gagal memenuhi pola, sering karena karakter seperti { dari parameter opsional. Perluas polanya agar cocok dengan route Anda. |
422 validation_failed | Kiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field. |
Pertanyaan
Tulis function middleware yang membaca sesi dan memanggil next dengan pengguna di konteks, lalu cantumkan di middleware server function. Handler membaca context.user, lengkap dengan tipe, di server.
Bisa. getRequestHeader dan getRequest dari @tanstack/react-start/server membaca request yang masuk di dalam handler, jadi Anda bisa mengambil halaman dari Referer tanpa mengirimnya dari browser.
Ya, selama ada server yang menjalankan server function Anda dan host tidak menulis ulang path /_serverFn/ ke shell SPA.
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.