웹사이트나 앱용 피드백 폼6분 분량
React Router로 인앱 피드백 폼 만들기
React Router 8 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.
로그인한 라우트를 감싸는 레이아웃에 피드백 폼 하나를 마운트하고 useFetcher로 리소스 라우트에 POST하세요. 그 액션은 쿠키 세션에서 사용자를 읽고, Referer에서 페이지를 가져와, 시크릿 키로 평점을 폼 백엔드에 전달합니다. JavaScript가 로드되지 않았을 때는 같은 액션이 원시 JSON으로 응답하는 대신 페이지로 리디렉션합니다.
메인 가이드의 React Router 탭은 fetcher로 공개 페이지 폼을 POST합니다. 여기서는 폼이 로그인 뒤 앱 안에 있고, 누가 보냈는지는 액션이 정합니다. 키, 허용 도메인, 기본 사항은 서버 프레임워크 문서 가이드를 참고하세요. 코드는 프레임워크 모드의 React Router 8을 대상으로 합니다.
레이아웃 fetcher가 바꾸는 것
레이아웃에 있는 피드백 폼은 모든 화면에 동시에 있고, React Router는 사용자가 이동하는 동안 이를 마운트된 채로 둡니다. 원하던 바이지만, 메인 가이드에서는 답할 필요가 없던 네 가지 질문이 생깁니다.
| 고려 사항 | 레이아웃에서 | 이 페이지의 처리 방식 |
|---|---|---|
| 누가 보냈는지 | 브라우저가 아무 사용자라고 주장할 수 있음 | 액션이 서명된 쿠키 세션을 읽음 |
| 어느 화면인지 | 폼은 어떤 자식 라우트가 표시 중인지 모름 | 각 라우트가 handle에 자기 이름을 적고, useMatches로 읽음 |
| 남은 상태 | fetcher의 data가 내비게이션 후에도 남음 | key 옵션으로 화면마다 fetcher 하나 |
| 아직 JavaScript 없음 | fetcher.Form은 실제 폼이므로 브라우저가 리소스 라우트로 POST함 | 액션이 문서 POST를 감지해 원래 페이지로 리디렉션 |
- 사용자가 레이아웃이 모든 로그인 라우트에 렌더링하는 폼에서 화면을 평가합니다.
- 하이드레이션 후에는 fetcher가 백그라운드에서 POST합니다. 액션이 쿠키 세션, Referer의 페이지, 화면 이름을 읽습니다.
- 액션이 사용자 정보를 붙여 시크릿 키로 Ikiform에 POST합니다.
- 결과가 data()로 돌아와 내비게이션 없이 그 자리에 표시됩니다.
- 하이드레이션 전에는 브라우저가 폼을 문서로 POST합니다. 액션은 내비게이션임을 보고, 결과를 쿼리에 담아 원래 페이지로 리디렉션합니다.
프로젝트 설정
create-react-router로 만든 프로젝트에서 관련된 파일들입니다.
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsx액션용 리소스 라우트와 로그인 화면용 경로 없는 레이아웃을 추가하세요. 액션이 있고 기본 export가 없는 모듈은 리소스 라우트이므로 아무것도 렌더링하지 않습니다.
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_ 접두사는 절대 붙이지 마세요. 클라이언트 번들에 들어갑니다.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string폼의 Fields 탭에 아래 정의를 붙여 넣으세요. 사람은 두 필드를 입력하고 나머지는 액션이 채우며, 세션의 email 덕분에 각 알림의 Reply가 그 사람에게 갑니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
rating에 9나 정의에 없는 필드를 보내면 액션이 받는 422를 볼 수 있습니다.만들기
쿠키 세션에서 사용자 읽기
createCookieSessionStorage는 secrets로 쿠키에 서명하므로, 사용자는 읽을 수는 있어도 바꿀 수는 없습니다. 로그인에서 이미 세션을 쓰고 있다면 그 스토리지를 재사용하고 readUser를 추가하세요.
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하면 빌드가 실패합니다.
리소스 라우트
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이고, 프로덕션에서는 내 도메인입니다.
폼
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가 같은 감사 문구를 보여 줍니다.
한 번만 마운트하기
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";
export default function AppLayout() {
return (
<>
<Outlet />
<aside aria-label="Feedback">
<FeedbackForm />
</aside>
</>
);
}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 레이트 리밋을 고르고, 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가 로컬 테스트로 집계됩니다.
작동 확인하기
react-router dev를 실행하고 로그인한 뒤 프로젝트 설정을 열어 Painful(불편함)을 고르고 코멘트를 추가하세요.- Submissions를 확인하세요.
screen은projects/settings이고,page에는 쿼리 문자열 없이 실제 ID가 있고,user_id와email이 내 계정과 일치합니다. - 다른 화면으로 이동하세요. 그 화면의 폼은 이전 감사 문구가 아니라 비어 있습니다.
- JavaScript를 끄고 다시 보내세요. 페이지가
?feedback=sent와 감사 문구와 함께 새로고침됩니다. - 세션 쿠키를 지우고 보내세요. 폼에 로그인 메시지가 표시됩니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
| 제출 후 브라우저에 원시 JSON이 표시됨 | POST가 내비게이션으로 도착했는데 액션이 data()를 반환했습니다. Sec-Fetch-Mode 분기를 확인하고, 서버 앞단에서 그 헤더를 지우는 것이 없는지 확인하세요. |
로컬 테스트 중 402 plan_required | POST에 로컬 origin이 없었습니다. 액션에서 Origin 헤더를 유지하고 localhost로 앱을 여세요. |
screen이 항상 unknown | 매칭된 어떤 라우트도 screen이 있는 handle을 export하지 않거나, 값에 대문자나 공백이 있습니다. 소문자로 된 경로 같은 이름을 쓰세요. |
| 전송 실패 시 에러 바운더리가 페이지를 대체함 | 액션에서 무언가가 예외를 던졌습니다. 예상되는 모든 실패에는 상태 코드와 함께 data()를 반환하세요. |
로그인 직후 readUser가 null을 반환함 | 쿠키를 쓴 코드와 이 코드의 SESSION_SECRET이 달라서 서명 확인이 실패합니다. 둘 다 하나의 스토리지 모듈을 쓰세요. |
질문
네. fetcher.Form의 action이 리소스 라우트를 가리키게 하세요. fetcher가 결과를 받도록 액션에서 data()를 반환하고, 요청이 브라우저 내비게이션이면 리디렉션해서 하이드레이션 전 POST에 원시 JSON이 표시되지 않게 하세요.
세션 스토리지의 getSession 함수로 Cookie 헤더에서 세션을 읽은 다음 사용자의 ID를 읽으세요. 폼이 POST한 사용자 ID는 절대 믿지 마세요.
아니요. ssr을 false로 설정하면 액션을 실행할 서버가 없습니다. clientAction에서 POST할 수는 있지만, 그러면 신원을 브라우저가 제공하므로 신뢰할 수 없습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.