웹사이트나 앱용 피드백 폼

웹사이트나 앱용 피드백 폼6분 분량

TanStack Start로 인앱 피드백 폼 만들기

TanStack Start 인앱 피드백 폼입니다. 세션 미들웨어가 붙은 POST 서버 함수, 컨텍스트로 쓰는 라우트 ID, 요청 헤더에서 읽는 페이지로 구성합니다.

Preet SutharIkiform 창업자

피드백용 POST 서버 함수를 작성하고, 세션을 읽어 사용자를 context에 넣는 함수 미들웨어를 붙이세요. 함수는 그 사용자, 요청의 Referer에서 얻은 페이지, 매칭된 라우트 ID를 평점에 추가한 뒤 시크릿 키로 폼 백엔드에 전달합니다. 폼을 로그인 레이아웃 라우트에 한 번 마운트하면 어느 화면에 있는지 스스로 압니다.

메인 가이드의 TanStack Start 탭은 누구에게서나 페이지 피드백을 받고, 페이지는 브라우저에서 설정합니다. 로그인한 앱 안에서는 더 잘할 수 있습니다. 서버는 누가 묻는지 알고, 라우터는 어느 화면이 열려 있는지 압니다. 계정 설정, 키, 허용 도메인은 서버 문서 가이드에서 한 번에 다룹니다.

누가 무엇을 제공하는지

Start에서 서버 함수는 /_serverFn/ 아래의 HTTP 엔드포인트이며, 컴포넌트는 생성된 클라이언트를 통해 이를 호출합니다. 입력의 어떤 값이든 호출하는 쪽이 다시 쓸 수 있습니다. 그래서 입력에는 사용자가 고른 것만 담고, 신원에 관한 모든 것은 핸들러보다 먼저 서버에서 실행되는 미들웨어에서 가져옵니다.

값유입 경로신뢰
rating, comment폼의 FormData사용자 본인의 말
user_id, email세션 미들웨어가 설정하는 context.user서버에서 봉인된 쿠키로부터 읽음
page핸들러 안의 getRequestHeader("referer")브라우저가 열린 페이지에서 함수를 호출하므로 헤더에 그 URL이 담김
screen가장 깊이 매칭된 routeId, 예: /_app/projects/$projectId/settings묶기 위한 레이블. 저장 전에 패턴으로 확인
TanStack Start 서버 함수가 피드백 답변에 사용자와 페이지를 추가하는 과정FeedbackFormFormData+routeIduserMiddleware세션쿠키읽기검증기점수,코멘트,화면형식핸들러사용자와Referer페이지추가Ikiform시크릿키, 저장오류결과폼 유지,코멘트보존/_serverFncontext.user데이터JSON사용자 없음TanStack Start 서버 함수가 피드백 답변에 사용자와 페이지를 추가하는 과정FeedbackFormFormData + routeIduserMiddleware세션 쿠키 읽기검증기점수, 코멘트, 화면 형식핸들러사용자와 Referer페이지 추가Ikiform시크릿 키, 저장오류 결과폼 유지, 코멘트 보존/_serverFncontext.user데이터JSON사용자 없음
  1. 폼이 점수와 코멘트를 모으고, 현재 라우트 ID를 추가하고, 서버 함수를 호출합니다.
  2. 서버에서 미들웨어가 세션을 읽고 사용자를 컨텍스트로 넘깁니다.
  3. 검증기가 FormData를 타입이 있는 객체로 바꾸고, 형식이 잘못된 화면 이름은 버립니다.
  4. 핸들러가 사용자가 없으면 거부하고, 사용자와 Referer 헤더의 페이지를 추가합니다.
  5. 시크릿 키로 Ikiform에 POST하고 sent를 반환하거나, 폼이 표시할 오류를 반환합니다.
사용자는 서버의 쿠키에서 옵니다. 폼 데이터의 어떤 값도 답변의 작성자를 바꿀 수 없습니다.

프로젝트 설정

이 트리는 로그인한 모든 화면을 감싸는 경로 없는 _app 레이아웃이 있는 파일 기반 라우팅을 가정합니다. session.ts는 Start 인증 가이드의 iron-session 헬퍼이며, 쿠키를 읽는 어떤 세션 라이브러리든 똑같이 동작합니다. users.ts의 findUser와 server/user.ts의 getCurrentUserFn은 직접 만든 사용자 조회 함수입니다.

프로젝트 트리
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는 접두사 없는 변수를 서버에서만 process.env로 읽습니다. VITE_ 접두사가 붙은 것은 브라우저용으로 번들링되므로 키에는 절대 붙이면 안 됩니다. 모듈 최상단이 아니라 핸들러 안에서 읽으세요.

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

폼의 Fields 탭에 필드 정의를 붙여 넣으세요. 폼에는 그중 두 개가 보이고 나머지 네 개는 서버 함수가 채우며, email 필드 덕분에 각 알림의 Reply가 사용자에게 갑니다.

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

이 화면은 어떠신가요?

서버가 요청에서 설정합니다.

rating: 6이나 정의에 없는 필드를 보내면 미리보기에 서버 함수가 받을 422가 표시됩니다.

만들기

세션 미들웨어

함수 미들웨어는 자신을 나열한 모든 서버 함수를 감쌉니다. next({ context })에 넣은 것은 핸들러에 타입과 함께 도착합니다. 예외를 던지지 않고 null을 넘기므로, 핸들러가 폼에 표시할 메시지로 응답할 수 있습니다.

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 } });
});

서버 함수

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는 원시 FormData를 받아 일반 객체를 반환합니다. 잘못된 점수는 예외 대신 null이 됩니다. 예외를 던지면 클라이언트에는 일반적인 실패로만 전달됩니다.
  • getRequestHeader는 요청 객체를 넘겨받지 않고도 핸들러 어디서든 들어오는 요청을 읽습니다.
  • Origin은 브라우저의 호출에서 전달됩니다. 아래에서 설명하듯 Ikiform이 POST를 로컬 테스트로 처리할지를 정합니다.
  • 컨텍스트에는 사용자 레코드 전체가 아니라 ID와 이메일만 넣습니다. 핸들러가 반환하는 것은 직렬화되어 브라우저로 갑니다.

폼

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>
  );
}
  • select를 준 useMatches는 마지막 매치의 routeId만 반환하므로, 컴포넌트는 라우터가 업데이트될 때마다가 아니라 화면이 바뀔 때만 다시 렌더링됩니다.
  • onSubmit이 기본 POST를 막고 함수를 호출합니다. 폼을 초기화하는 것이 없으므로 오류 후에도 코멘트가 입력란에 남아 있습니다.
  • pending이 호출하는 동안 버튼을 비활성화해 중복 전송을 막습니다.

레이아웃 라우트에 마운트하기

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는 로그아웃한 사용자를 화면에서 막지만, 피드백을 보호하는 것은 아닙니다. 누구나 /_serverFn/을 직접 호출할 수 있으므로 미들웨어가 다시 확인합니다. 라우트 ID에는 경로 없는 레이아웃이 포함되므로 screen에 /_app 접두사가 붙습니다. 짧은 이름을 원하면 저장 전에 떼어 내세요.

허니팟, 사람 확인, 한도

_gotcha 입력은 검증기를 그대로 통과하고, 값이 채워지면 Ikiform이 스팸으로 분류합니다. 시크릿 키로 보낸 POST는 사람 확인을 건너뛰는데, 세션 뒤에서는 괜찮습니다. 봇에게는 세션이 없습니다. 남은 것은 로그인한 계정이 반복해서 POST하는 경우이며, Ikiform은 그 모두를 서버의 주소로 셉니다.

서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.

SSR, SPA 모드, 사전 렌더링

폼은 완전한 SSR에서도, tanstackStart() 옵션에 spa: { enabled: true }를 준 경우에도 똑같이 동작합니다. SPA 모드에서는 루트 셸만 사전 렌더링되고 화면은 브라우저에서 렌더링되지만, 서버 함수에는 여전히 실행 중인 서버가 필요합니다. 호스트가 모든 경로를 셸로 다시 쓴다면 그대로 통과시킬 경로에 /_serverFn/*를 추가하세요. 로그인한 화면은 사전 렌더링하지 마세요. 빌드 시점에는 사용자가 없습니다.

Start 문서에는 일반 HTML 폼에서 서버 함수를 호출하는 .url 속성도 나옵니다. 이 페이지에서는 쓰지 않습니다. 로그인한 화면은 누군가 코멘트를 입력하기 전에 하이드레이션되고, 스크립트 없는 POST를 지원하려면 함수가 데이터 대신 리디렉션으로 응답해야 합니다. 이 폼에는 JavaScript가 필요하다고 보세요.

배포

호스트설정
VercelVite 플러그인에 nitro/vite의 nitro()를 추가하고 배포한 뒤, 프로젝트의 환경 변수에 IKIFORM_SECRET_KEY를 추가하세요.
Netlify@netlify/vite-plugin-tanstack-start의 netlify()를 추가하고 사이트의 환경 변수에 키를 설정하세요.
Cloudflare@cloudflare/vite-plugin의 cloudflare({ viteEnvironment: { name: "ssr" } })를 추가하고, nodejs_compat를 켜고, wrangler secret put으로 키를 저장하세요. 호환성 날짜가 2025-04-01 이후라면 이 플래그가 process.env를 시크릿으로 채웁니다.

localhost에서 무료로 테스트하기

플랜이 없으면 워크스페이스는 Origin 헤더가 localhost일 때 월 50건의 테스트 제출을 받습니다. 서버 자체의 fetch에는 origin이 없으므로, 헤더를 전달하지 않은 POST는 402 plan_required로 응답받고 보류됩니다. 헤더를 전달하면 http://localhost:3000의 vite dev가 브라우저의 origin을 넘겨 각 답변이 테스트로 집계됩니다. 프로덕션에서는 같은 줄이 앱의 도메인을 보냅니다.

작동 확인하기

  1. vite dev를 실행하고 로그인한 뒤 프로젝트 설정을 열어 코멘트와 함께 2점을 보내세요.
  2. Submissions에서 답변에 user_id, email, 라우트 ID로 설정된 screen, 실제 프로젝트 ID가 담긴 page가 있습니다.
  3. 다른 탭에서 로그아웃하고 다시 보내세요. 폼에 로그아웃 메시지가 표시되고 코멘트는 유지됩니다.
  4. 브라우저 콘솔에서 폼 데이터에 위조한 사용자 ID를 넣어 함수를 호출하세요. 저장된 user_id는 여전히 세션의 것입니다.
  5. 알림 이메일에 답장하세요. 로그인한 사용자에게 갑니다.

주의할 점

문제해결 방법
src/start.ts를 추가한 뒤 서버 함수의 CSRF 확인이 사라짐src/start.ts가 없으면 Start가 서버 함수용 CSRF 미들웨어를 스스로 추가합니다. 직접 정의하면 그 파일에서 서버 함수로 필터링한 createCsrfMiddleware를 직접 등록하세요.
로컬 테스트에서 402 plan_requiredOrigin 헤더가 Ikiform에 도달하지 않았습니다. 전달하는 줄을 확인하고, LAN 주소가 아니라 localhost로 앱을 여세요.
Cloudflare에서 IKIFORM_SECRET_KEY가 undefinedWorkers는 nodejs_compat가 켜져 있고 호환성 날짜가 2025-04-01 이후일 때만 process.env를 채웁니다. wrangler.jsonc에서 둘 다 확인하거나, cloudflare:workers의 env에서 키를 읽으세요.
screen이 항상 unknown라우트 ID가 패턴에 맞지 않았습니다. 선택적 파라미터의 { 같은 문자 때문인 경우가 많습니다. 라우트에 맞게 패턴을 넓히세요.
422 validation_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.