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

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

2026-10-25 게시 · https://ikiform.com/ko/guides/feedback-form/tanstack-start

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

[메인 가이드의 TanStack Start 탭](https://ikiform.com/ko/guides/feedback-form.md#form)은 누구에게서나 페이지 피드백을 받고, 페이지는 브라우저에서 설정합니다. 로그인한 앱 안에서는 더 잘할 수 있습니다. 서버는 누가 묻는지 알고, 라우터는 어느 화면이 열려 있는지 압니다. 계정 설정, 키, 허용 도메인은 [서버 문서 가이드](https://ikiform.com/ko/docs/guides/server.md)에서 한 번에 다룹니다.

## 누가 무엇을 제공하는지

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

| 값 | 유입 경로 | 신뢰 |
| --- | --- | --- |
| `rating`, `comment` | 폼의 `FormData` | 사용자 본인의 말 |
| `user_id`, `email` | 세션 미들웨어가 설정하는 `context.user` | 서버에서 봉인된 쿠키로부터 읽음 |
| `page` | 핸들러 안의 `getRequestHeader("referer")` | 브라우저가 열린 페이지에서 함수를 호출하므로 헤더에 그 URL이 담김 |
| `screen` | 가장 깊이 매칭된 `routeId`, 예: `/_app/projects/$projectId/settings` | 묶기 위한 레이블. 저장 전에 패턴으로 확인 |

다이어그램: TanStack Start 서버 함수가 피드백 답변에 사용자와 페이지를 추가하는 과정

1. 폼이 점수와 코멘트를 모으고, 현재 라우트 ID를 추가하고, 서버 함수를 호출합니다.
2. 서버에서 미들웨어가 세션을 읽고 사용자를 컨텍스트로 넘깁니다.
3. 검증기가 FormData를 타입이 있는 객체로 바꾸고, 형식이 잘못된 화면 이름은 버립니다.
4. 핸들러가 사용자가 없으면 거부하고, 사용자와 Referer 헤더의 페이지를 추가합니다.
5. 시크릿 키로 Ikiform에 POST하고 sent를 반환하거나, 폼이 표시할 오류를 반환합니다.

사용자는 서버의 쿠키에서 옵니다. 폼 데이터의 어떤 값도 답변의 작성자를 바꿀 수 없습니다.

## 프로젝트 설정

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

`프로젝트 트리`:

```text
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`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup
```

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

폼: In-app feedback

- **이 화면은 어떠신가요?** (`rating`, rating, 필수)
- **무엇이 더 좋아지면 될까요?** (`comment`, textarea)
- **페이지** (`page`, url)
- **화면** (`screen`, text)
- **사용자** (`user_id`, text)
- **이메일** (`email`, email)

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

## 만들기

### 세션 미들웨어

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

`src/lib/user-middleware.ts`:

```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`:

```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`:

```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`:

```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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## SSR, SPA 모드, 사전 렌더링

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

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

## 배포

| 호스트 | 설정 |
| --- | --- |
| Vercel | Vite 플러그인에 `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_required` | `Origin` 헤더가 Ikiform에 도달하지 않았습니다. 전달하는 줄을 확인하고, LAN 주소가 아니라 `localhost`로 앱을 여세요. |
| Cloudflare에서 `IKIFORM_SECRET_KEY`가 undefined | Workers는 `nodejs_compat`가 켜져 있고 호환성 날짜가 2025-04-01 이후일 때만 `process.env`를 채웁니다. `wrangler.jsonc`에서 둘 다 확인하거나, `cloudflare:workers`의 `env`에서 키를 읽으세요. |
| `screen`이 항상 `unknown` | 라우트 ID가 패턴에 맞지 않았습니다. 선택적 파라미터의 `{` 같은 문자 때문인 경우가 많습니다. 라우트에 맞게 패턴을 넓히세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### TanStack Start 서버 함수 안에서 현재 사용자를 어떻게 가져오나요?

세션을 읽고 사용자를 컨텍스트에 담아 next를 호출하는 함수 미들웨어를 작성하고, 서버 함수의 middleware에 나열하세요. 핸들러는 서버에서 타입이 붙은 context.user를 읽습니다.

### TanStack Start 서버 함수에서 요청 헤더를 읽을 수 있나요?

네. @tanstack/react-start/server의 getRequestHeader와 getRequest가 핸들러 안에서 들어오는 요청을 읽으므로, 브라우저에서 보내지 않고도 Referer에서 페이지를 가져올 수 있습니다.

### 피드백 폼이 TanStack Start SPA 모드에서도 동작하나요?

네. 서버가 서버 함수를 실행하고, 호스트가 /_serverFn/ 경로를 SPA 셸로 다시 쓰지 않는다면 동작합니다.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [React Router 피드백 폼](https://ikiform.com/ko/guides/feedback-form/react-router.md): React Router 8 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.
- [Next.js 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nextjs.md): Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
- [React 피드백 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md): 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
