# React Router로 인앱 피드백 폼 만들기

> React Router 8 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.

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

로그인한 라우트를 감싸는 레이아웃에 피드백 폼 하나를 마운트하고 `useFetcher`로 리소스 라우트에 POST하세요. 그 액션은 쿠키 세션에서 사용자를 읽고, `Referer`에서 페이지를 가져와, 시크릿 키로 평점을 폼 백엔드에 전달합니다. JavaScript가 로드되지 않았을 때는 같은 액션이 원시 JSON으로 응답하는 대신 페이지로 리디렉션합니다.

[메인 가이드의 React Router 탭](https://ikiform.com/ko/guides/feedback-form.md#form)은 fetcher로 공개 페이지 폼을 POST합니다. 여기서는 폼이 로그인 뒤 앱 안에 있고, 누가 보냈는지는 액션이 정합니다. 키, 허용 도메인, 기본 사항은 [서버 프레임워크 문서 가이드](https://ikiform.com/ko/docs/guides/server.md)를 참고하세요. 코드는 프레임워크 모드의 React Router 8을 대상으로 합니다.

## 레이아웃 fetcher가 바꾸는 것

레이아웃에 있는 피드백 폼은 모든 화면에 동시에 있고, React Router는 사용자가 이동하는 동안 이를 마운트된 채로 둡니다. 원하던 바이지만, 메인 가이드에서는 답할 필요가 없던 네 가지 질문이 생깁니다.

| 고려 사항 | 레이아웃에서 | 이 페이지의 처리 방식 |
| --- | --- | --- |
| 누가 보냈는지 | 브라우저가 아무 사용자라고 주장할 수 있음 | 액션이 서명된 쿠키 세션을 읽음 |
| 어느 화면인지 | 폼은 어떤 자식 라우트가 표시 중인지 모름 | 각 라우트가 `handle`에 자기 이름을 적고, `useMatches`로 읽음 |
| 남은 상태 | fetcher의 `data`가 내비게이션 후에도 남음 | `key` 옵션으로 화면마다 fetcher 하나 |
| 아직 JavaScript 없음 | `fetcher.Form`은 실제 폼이므로 브라우저가 리소스 라우트로 POST함 | 액션이 문서 POST를 감지해 원래 페이지로 리디렉션 |

다이어그램: React Router 피드백 POST가 같은 리소스 라우트에 도달하는 두 가지 방법

1. 사용자가 레이아웃이 모든 로그인 라우트에 렌더링하는 폼에서 화면을 평가합니다.
2. 하이드레이션 후에는 fetcher가 백그라운드에서 POST합니다. 액션이 쿠키 세션, Referer의 페이지, 화면 이름을 읽습니다.
3. 액션이 사용자 정보를 붙여 시크릿 키로 Ikiform에 POST합니다.
4. 결과가 data()로 돌아와 내비게이션 없이 그 자리에 표시됩니다.
5. 하이드레이션 전에는 브라우저가 폼을 문서로 POST합니다. 액션은 내비게이션임을 보고, 결과를 쿼리에 담아 원래 페이지로 리디렉션합니다.

액션 하나, 호출자 두 종류. 브라우저 내비게이션만 리디렉션을 받고, fetcher 호출은 데이터를 받습니다.

## 프로젝트 설정

`create-react-router`로 만든 프로젝트에서 관련된 파일들입니다.

`프로젝트 트리`:

```text
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx
```

액션용 리소스 라우트와 로그인 화면용 경로 없는 레이아웃을 추가하세요. 액션이 있고 기본 export가 없는 모듈은 리소스 라우트이므로 아무것도 렌더링하지 않습니다.

`app/routes.ts`:

```ts
import { layout, route, type RouteConfig } from "@react-router/dev/routes";

export default [
  route("api/feedback", "routes/api.feedback.ts"),
  layout("routes/app-layout.tsx", [route("projects/:id/settings", "routes/project-settings.tsx")]),
] satisfies RouteConfig;
```

**API keys**에서 `submissions:write` 스코프로 시크릿 키를 만드세요. 서버는 두 값을 `process.env`에서 읽습니다. 서버가 실행되는 곳에 설정하고, 키에 `VITE_` 접두사는 절대 붙이지 마세요. 클라이언트 번들에 들어갑니다.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string
```

폼의 **Fields** 탭에 아래 정의를 붙여 넣으세요. 사람은 두 필드를 입력하고 나머지는 액션이 채우며, 세션의 `email` 덕분에 각 알림의 **Reply**가 그 사람에게 갑니다.

폼: In-app feedback

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

`rating`에 `9`나 정의에 없는 필드를 보내면 액션이 받는 `422`를 볼 수 있습니다.

## 만들기

### 쿠키 세션에서 사용자 읽기

`createCookieSessionStorage`는 `secrets`로 쿠키에 서명하므로, 사용자는 읽을 수는 있어도 바꿀 수는 없습니다. 로그인에서 이미 세션을 쓰고 있다면 그 스토리지를 재사용하고 `readUser`를 추가하세요.

`app/sessions.server.ts`:

```ts
import { createCookieSessionStorage } from "react-router";

interface SessionData {
  userId: string;
  email: string;
}

export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData>({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET ?? ""],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function readUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const id = session.get("userId");
  const email = session.get("email");
  return id && email ? { id, email } : null;
}
```

`.server` 접미사는 모듈을 클라이언트 번들에서 제외합니다. 컴포넌트에서 import하면 빌드가 실패합니다.

### 리소스 라우트

`app/routes/api.feedback.ts`:

```ts
import { data, redirect } from "react-router";
import { readUser } from "~/sessions.server";
import type { Route } from "./+types/api.feedback";

const SCREEN = /^[a-z0-9/._-]{1,120}$/;

function samePage(referer: string | null, origin: string) {
  try {
    const url = new URL(referer ?? "");
    return url.origin === origin ? url.pathname : "";
  } catch {
    return "";
  }
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const origin = new URL(request.url).origin;
  const path = samePage(request.headers.get("Referer"), origin);
  const native = request.headers.get("Sec-Fetch-Mode") === "navigate";

  function reply(status: "sent" | "error", message: string, code = 200) {
    if (native) return redirect(`${path || "/"}?feedback=${status}`);
    return data({ status, message }, { status: code });
  }

  const user = await readUser(request);
  if (!user) return reply("error", "You were signed out. Sign in, then send it again.", 401);

  const rating = Number(form.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) return reply("error", "Choose a score first.", 400);
  const screen = String(form.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: origin,
      },
      body: JSON.stringify({
        rating,
        comment: String(form.get("comment") ?? "").slice(0, 4000),
        page: path ? origin + path : "",
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return reply("error", "Feedback is unreachable right now. Try again shortly.", 502);
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return reply("sent", "Got it. Thank you.");
  return reply("error", result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "That didn't go through.", 502);
}
```

- `samePage`는 `Referer`가 내 origin일 때만 경로를 남기고, 앱 URL에 ID와 토큰이 담기는 쿼리 문자열은 버립니다.
- 브라우저가 직접 폼을 페이지 로드로 제출하면 `Sec-Fetch-Mode`가 `navigate`입니다. fetcher는 백그라운드 요청으로 보내므로 `data()`를 받고, 나머지는 모두 리디렉션을 받습니다.
- 오류는 예외 대신 4xx나 5xx `data()`를 씁니다. 예외를 던지면 가장 가까운 `ErrorBoundary`까지 올라가 사용자가 작업하던 화면을 대체합니다.
- `Origin`은 요청 자체의 origin으로 설정됩니다. `react-router dev`에서는 `http://localhost:5173`이고, 프로덕션에서는 내 도메인입니다.

### 폼

`app/components/feedback-form.tsx`:

```tsx
import { useFetcher, useMatches, useSearchParams } from "react-router";
import type { action } from "~/routes/api.feedback";

const SCORES = ["Blocked", "Painful", "Okay", "Smooth", "Excellent"];

function useScreen() {
  const named = useMatches().flatMap((match) => {
    const screen = (match.handle as { screen?: string } | undefined)?.screen;
    return screen ? [screen] : [];
  });
  return named.at(-1) ?? "unknown";
}

export function FeedbackForm() {
  const screen = useScreen();
  const fetcher = useFetcher<typeof action>({ key: `feedback:${screen}` });
  const [params] = useSearchParams();
  const busy = fetcher.state !== "idle";
  const fallback = params.get("feedback");
  const sent = fetcher.data?.status === "sent" || fallback === "sent";
  const error = fetcher.data?.status === "error" ? fetcher.data.message : fallback === "error" ? "That didn't go through." : "";

  if (sent) return <p role="status">Got it. Thank you.</p>;

  return (
    <fetcher.Form method="post" action="/api/feedback" aria-busy={busy}>
      <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={3} maxLength={4000} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={busy}>{busy ? "Sending" : "Send feedback"}</button>
    </fetcher.Form>
  );
}
```

- `useScreen`은 매칭된 라우트를 훑어 가장 깊은 `handle.screen`을 남기므로, 중첩 라우트가 부모보다 더 정확하게 자기 이름을 붙일 수 있습니다.
- fetcher `key`에 화면이 포함됩니다. 한 화면에서 보낸 뒤 다음 화면에서는 이전 감사 문구 대신 새 폼이 보입니다.
- 매칭된 로더가 재검증되는 동안 `fetcher.state`는 `submitting` 다음 `loading`입니다. 둘 다 처리 중으로 보므로 답이 올 때까지 버튼이 비활성화됩니다.
- JavaScript가 없으면 리디렉션이 `?feedback=sent`와 함께 페이지로 돌아오고, `useSearchParams`가 같은 감사 문구를 보여 줍니다.

### 한 번만 마운트하기

`app/routes/app-layout.tsx`:

```tsx
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";

export default function AppLayout() {
  return (
    <>
      <Outlet />
      <aside aria-label="Feedback">
        <FeedbackForm />
      </aside>
    </>
  );
}
```

`app/routes/project-settings.tsx`:

```tsx
export const handle = { screen: "projects/settings" };

export default function ProjectSettings() {
  return <h1>Project settings</h1>;
}
```

모든 프로젝트의 설정 화면은 `projects/settings`로 보고되어 함께 평균이 내지고, 실제 ID가 담긴 구체적인 경로는 여전히 `page`에 있습니다.

## 허니팟, 사람 확인, 한도

`_gotcha` 입력은 액션을 그대로 통과하고, 값이 채워지면 스팸으로 저장됩니다. 시크릿 키 POST에서는 Ikiform의 사람 확인을 건너뛰고, 세션 확인이 그 자리를 대신합니다. 서명된 쿠키가 없는 방문자는 아무것도 POST되지 않습니다. 남은 것은 한 계정이 반복해서 POST하는 경우이며, Ikiform은 그 모두를 서버의 IP로 셉니다.

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

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

액션에는 서버가 필요합니다. `react-router.config.ts`에 `ssr: false`를 주면 빌드에 서버가 없어 서버 액션이 실행되지 않고, 신뢰할 수 있는 신원 없이 브라우저에서 POST하는 `clientAction`으로 돌아가게 됩니다. 액션은 여전히 요청 시 실행되는 별도 라우트이므로 마케팅 페이지를 사전 렌더링하는 것은 괜찮습니다. 앱의 로그인 부분에는 `ssr: true`를 유지하세요.

## 배포

| 호스트 | 해결 방법 |
| --- | --- |
| Vercel | `@vercel/react-router`를 설치하고 `react-router.config.ts`의 `presets`에 `vercelPreset()`을 추가하세요. 두 변수를 프로젝트 설정에 추가하세요. |
| Netlify | Vite 플러그인에 `@netlify/vite-plugin-react-router`를 추가하세요. 액션은 Netlify Functions로 실행됩니다. 두 변수를 사이트의 환경 설정에 넣으세요. |
| Cloudflare | `@cloudflare/vite-plugin`과 함께 Cloudflare의 React Router 템플릿을 쓰세요. 바인딩은 `import { env } from "cloudflare:workers"`로 읽습니다. React Router 8에서는 액션의 `context`에 더 이상 `cloudflare.env`가 없습니다. 키는 거기서 읽고, Worker 시크릿으로 저장하며, 로컬 실행에는 `.dev.vars`를 쓰세요. |

## localhost에서 무료로 테스트하기

Ikiform은 플랜이 없는 워크스페이스에 `localhost`에서 월 50건의 무료 테스트 제출을 주며, `Origin` 헤더로 판단합니다. 액션의 `fetch`에는 직접 추가하지 않는 한 `Origin`이 없고, origin 없는 POST는 실제 서버 POST로 처리되어 `402 plan_required`로 플랜을 고를 때까지 보류됩니다. 요청의 origin을 전달하면 `react-router dev`가 로컬 테스트로 집계됩니다.

## 작동 확인하기

1. `react-router dev`를 실행하고 로그인한 뒤 프로젝트 설정을 열어 **Painful**(불편함)을 고르고 코멘트를 추가하세요.
2. **Submissions**를 확인하세요. `screen`은 `projects/settings`이고, `page`에는 쿼리 문자열 없이 실제 ID가 있고, `user_id`와 `email`이 내 계정과 일치합니다.
3. 다른 화면으로 이동하세요. 그 화면의 폼은 이전 감사 문구가 아니라 비어 있습니다.
4. JavaScript를 끄고 다시 보내세요. 페이지가 `?feedback=sent`와 감사 문구와 함께 새로고침됩니다.
5. 세션 쿠키를 지우고 보내세요. 폼에 로그인 메시지가 표시됩니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 제출 후 브라우저에 원시 JSON이 표시됨 | POST가 내비게이션으로 도착했는데 액션이 `data()`를 반환했습니다. `Sec-Fetch-Mode` 분기를 확인하고, 서버 앞단에서 그 헤더를 지우는 것이 없는지 확인하세요. |
| 로컬 테스트 중 `402 plan_required` | POST에 로컬 origin이 없었습니다. 액션에서 `Origin` 헤더를 유지하고 `localhost`로 앱을 여세요. |
| `screen`이 항상 `unknown` | 매칭된 어떤 라우트도 `screen`이 있는 `handle`을 export하지 않거나, 값에 대문자나 공백이 있습니다. 소문자로 된 경로 같은 이름을 쓰세요. |
| 전송 실패 시 에러 바운더리가 페이지를 대체함 | 액션에서 무언가가 예외를 던졌습니다. 예상되는 모든 실패에는 상태 코드와 함께 `data()`를 반환하세요. |
| 로그인 직후 `readUser`가 null을 반환함 | 쿠키를 쓴 코드와 이 코드의 `SESSION_SECRET`이 달라서 서명 확인이 실패합니다. 둘 다 하나의 스토리지 모듈을 쓰세요. |

## 질문

### useFetcher 폼이 리소스 라우트로 POST할 수 있나요?

네. fetcher.Form의 action이 리소스 라우트를 가리키게 하세요. fetcher가 결과를 받도록 액션에서 data()를 반환하고, 요청이 브라우저 내비게이션이면 리디렉션해서 하이드레이션 전 POST에 원시 JSON이 표시되지 않게 하세요.

### React Router 액션에서 로그인한 사용자를 어떻게 가져오나요?

세션 스토리지의 getSession 함수로 Cookie 헤더에서 세션을 읽은 다음 사용자의 ID를 읽으세요. 폼이 POST한 사용자 ID는 절대 믿지 마세요.

### React Router SPA 모드에서도 동작하나요?

아니요. ssr을 false로 설정하면 액션을 실행할 서버가 없습니다. clientAction에서 POST할 수는 있지만, 그러면 신원을 브라우저가 제공하므로 신뢰할 수 없습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [TanStack Start 피드백 폼](https://ikiform.com/ko/guides/feedback-form/tanstack-start.md): TanStack Start 인앱 피드백 폼입니다. 세션 미들웨어가 붙은 POST 서버 함수, 컨텍스트로 쓰는 라우트 ID, 요청 헤더에서 읽는 페이지로 구성합니다.
- [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): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
