웹사이트나 앱용 피드백 폼6분 분량
Next.js로 인앱 피드백 폼 만들기
Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
피드백을 받고 싶은 화면에 1~5점 평점이 있는 작은 클라이언트 폼을 두고 서버 액션을 가리키게 하세요. 액션은 세션 쿠키에서 로그인한 사용자를 읽고, 요청의 Referer에서 페이지를 가져와, 시크릿 키로 모든 것을 폼 백엔드에 전달합니다. 사용자가 누구인지는 브라우저가 보내지 않으므로 아무도 다른 사람인 척 피드백을 보낼 수 없습니다.
메인 가이드의 Next.js 탭은 모든 방문자를 위한 페이지 피드백 폼입니다. 이 페이지는 사람들이 로그인해 있는 앱 내부를 위한 것입니다. 거기서는 누가 쓰고 있는지, 어느 화면에 있는지 이미 알고 있으므로 폼은 두 가지만 묻고 나머지는 서버가 채웁니다. 제품 설정, 키, 허용 도메인은 Next.js 문서 가이드에 있습니다.
사용자가 로그인해 있을 때 달라지는 점
공개 피드백 폼은 브라우저가 주는 것을 받습니다. 인앱 폼은 그러면 안 됩니다. 브라우저가 user_id를 보내면 누구나 개발자 도구에서 바꿔 동료나 고객인 척 피드백을 보낼 수 있습니다. 각 값은 그 값을 믿고 맡길 수 있는 곳에서 와야 합니다.
| 값 | 출처 | 이유 |
|---|---|---|
rating, comment | 폼 | 사용자만 알고 있음 |
user_id, email | 액션에서 읽는 세션 쿠키 | 브라우저는 서명된 세션을 위조할 수 없음 |
page | 액션 요청의 Referer 헤더 | 서버 액션은 자신을 렌더링한 페이지로 POST하므로 헤더에 전체 URL이 담김 |
screen | projects/[id]/settings처럼 페이지가 넘기는 prop | 모든 프로젝트의 설정 페이지를 한 이름으로 묶어 평균이 의미를 가짐 |
브라우저를 거치는 값은 screen 하나뿐입니다. 권한이 아니라 묶기 위한 레이블이며, 액션이 저장 전에 형식을 확인합니다.
- 사용자가 점수를 고르고 코멘트를 쓸 수도 있습니다. 폼이 화면 이름과 함께 보냅니다.
- 서버 액션이 폼이 있는 페이지로의 POST로 실행되며, 세션 쿠키와 Referer의 페이지를 함께 받습니다.
- getUser()가 세션 쿠키를 읽고 확인합니다. 유효한 세션이 없으면 아무것도 POST하지 않습니다.
- 액션이 사용자의 ID, 이메일, 페이지 주소를 추가하고 시크릿 키로 Ikiform에 POST합니다.
- Ikiform의 응답이 액션 상태가 됩니다. 감사 문구이거나, 코멘트가 입력된 채로 남은 오류입니다.
프로젝트 설정
파일 네 개이며, 그중 하나는 아마 이미 있을 겁니다. App Router 레이아웃은 일반적인 구조입니다.
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localAPI keys에서 submissions:write 스코프로 시크릿 키를 만들고 .env.local에 넣으세요. NEXT_PUBLIC_ 접두사는 절대 붙이지 마세요. 모든 브라우저로 배포됩니다.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses폼의 Fields 탭에 이 정의를 붙여 넣으세요. 사용자에게는 처음 두 필드가 보이고 나머지 네 개는 서버가 설정합니다. email 필드가 있으므로 알림의 Reply는 바로 사용자에게 갑니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
0이나 알 수 없는 필드 이름을 넣어 보면, 미리보기가 액션이 돌려받는 것과 같은 422로 응답합니다.만들기
사용자는 한곳에서 읽기
Next.js는 데이터 접근 계층을 권장합니다. 세션을 읽고 확인하는 서버 전용 함수 하나를 모든 액션에서 쓰는 것입니다. React의 cache 덕분에 한 요청에서 여러 번 호출해도 쿠키는 한 번만 읽습니다. decrypt는 기존 세션 코드에 있는 것을 쓰세요. 인증 라이브러리를 쓴다면 여기서 그 라이브러리의 사용자를 반환하세요.
import "server-only";
import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";
export interface User {
id: string;
email: string;
}
export const getUser = cache(async (): Promise<User | null> => {
const token = (await cookies()).get("session")?.value;
if (!token) return null;
const session = await decrypt(token);
return session?.userId ? { id: session.userId, email: session.email } : null;
});서버 액션
"use server";
import { headers } from "next/headers";
import { getUser } from "@/lib/user";
export interface FeedbackState {
status: "idle" | "sent" | "error";
message: string;
comment?: string;
}
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null) {
try {
const url = new URL(referer ?? "");
return url.origin + url.pathname;
} catch {
return "";
}
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
const user = await getUser();
if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return { status: "error", message: "Pick a score from 1 to 5.", comment };
}
const request = await headers();
const screen = String(formData.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: request.get("origin") ?? "",
},
body: JSON.stringify({
rating,
comment,
page: pageFrom(request.get("referer")),
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: user.id,
email: user.email,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
}
const result = await response.json().catch(() => null);
if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
return {
status: "error",
message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
comment,
};
}- 세션 확인이 가장 먼저 오며 예외를 던지지 않습니다. 코멘트를 쓰는 도중 세션이 만료된 사용자는 오류 페이지가 아니라 메시지를 보고 코멘트도 유지됩니다.
pageFrom은Referer의 origin과 경로만 남깁니다. 앱의 쿼리 문자열에는 피드백 받은편지함에 두고 싶지 않은 ID와 토큰이 들어 있는 경우가 많습니다.screen은 경로 같은 짧은 패턴과 맞아야 합니다. 그 밖의 값은unknown으로 저장되므로 조작된 POST가 열을 쓰레기로 채울 수 없습니다.Origin은 브라우저의 origin을 전달합니다. 프로덕션에서는 내 도메인이고, 내 컴퓨터에서는localhost입니다. 아래에서 설명하듯 로컬 테스트가 무료인 이유가 이것입니다.AbortSignal.timeout은 네트워크가 느릴 때 버튼이 끝없이 돌지 않게 합니다.
폼
"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];
export function FeedbackForm({ screen }: { screen: string }) {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.status === "sent") return <p role="status">{state.message}</p>;
return (
<form action={action} 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} defaultValue={state.comment} />
<input type="hidden" name="screen" value={screen} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}- 액션은 바로
action에 들어갑니다. Next.js가 실제 폼 POST로 렌더링하므로, JavaScript가 로드되기 전에 클릭한 사용자의 피드백도 전송됩니다. useActionState의pending이 액션이 실행되는 동안 버튼을 비활성화해 중복 전송을 막습니다.- React는 액션 후 폼을 초기화합니다. 오류가 나면
defaultValue={state.comment}가 코멘트를 되돌려 놓으므로 아무도 두 번 입력하지 않습니다. - 레이블이 점수에 이름을 붙입니다.
Broken과Great는1과5보다 많은 것을 말하며, 저장되는 값은 여전히 숫자입니다.
화면에 넣기
import { FeedbackForm } from "@/app/feedback/feedback-form";
export default function ProjectSettingsPage() {
return (
<main>
<h1>Project settings</h1>
<aside>
<FeedbackForm screen="projects/[id]/settings" />
</aside>
</main>
);
}라우트의 폴더 경로를 screen으로 넘기세요. 그러면 모든 프로젝트의 설정 페이지가 한 이름을 공유하고, 실제 ID가 담긴 전체 주소는 여전히 page에 있습니다.
허니팟, 사람 확인, 한도
hidden _gotcha 입력은 그대로 전달됩니다. Ikiform은 값이 채워진 것을 스팸으로 저장합니다. 시크릿 키가 있는 POST는 사람 확인을 건너뛰므로 사람 확인은 실행되지 않지만, 여기서는 괜찮습니다. 액션이 세션 없는 사람을 거부하고, 봇에게는 세션이 없습니다. 남은 위험은 로그인한 계정 하나가 반복해서 보내는 것입니다. Ikiform의 레이트 리밋은 서버의 주소만 보므로 맞는 프리셋을 고르세요.
서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.
렌더링과 캐싱
폼은 렌더링 시점에 아무것도 읽지 않습니다. cookies()나 headers()를 호출하지 않으므로 페이지를 동적으로 만들지 않으며, Cache Components를 켜도 <Suspense> 경계 없이 정적 셸에 둘 수 있습니다. 요청을 다루는 것은 액션뿐이고, 액션은 항상 요청마다 실행됩니다. 페이지가 이미 다른 이유로 세션을 읽고 있다면 달라지는 것은 없습니다.
배포
| 호스트 | 해결 방법 |
|---|---|
| Vercel | 프로젝트의 Environment Variables에서 Production과 Preview에 IKIFORM_SECRET_KEY를 추가하세요. 액션은 별도 설정 없이 함수로 실행됩니다. |
| Netlify | Netlify는 자체 연동으로 Next.js를 빌드합니다. 사이트의 환경 변수에 Functions 스코프로 키를 추가하고 다시 배포하세요. |
| Cloudflare | Cloudflare는 OpenNext 연동으로 Next.js를 Workers에서 실행합니다. 키를 Worker 시크릿으로 저장하고, Worker 빌드를 로컬에서 미리 볼 때는 .dev.vars에 넣으세요. |
호스트를 바꾸는 프록시나 CDN 뒤에서는 Next.js가 Origin이 맞지 않는 액션을 거부합니다. next.config.ts의 experimental.serverActions.allowedOrigins에 그 도메인을 추가하세요.
localhost에서 무료로 테스트하기
플랜이 없는 워크스페이스는 월 50건의 테스트 제출을 받지만 localhost에서 온 것만 받으며, Ikiform은 이를 Origin 헤더로 판단합니다. 서버 측 fetch는 자체 Origin을 보내지 않으므로, 이를 전달하는 줄이 없으면 모든 로컬 테스트가 402 plan_required로 돌아와 보류됩니다. 그 줄이 있으면 http://localhost:3000의 next dev는 로컬 테스트로 집계되고, 프로덕션 POST에는 실제 도메인이 담깁니다.
작동 확인하기
next dev를 실행하고 로그인한 뒤 프로젝트 설정을 열어 코멘트와 함께 점수 2를 보내세요.- Submissions를 여세요. 답변에
user_id,email,projects/[id]/settings로 설정된screen, 그리고 실제 프로젝트 ID가 있고 쿼리 문자열은 없는page가 있습니다. - 다른 탭에서 로그아웃한 뒤 다시 보내세요. 폼에 로그인 메시지가 표시되고 코멘트는 입력란에 그대로 있습니다.
- JavaScript를 끄고 한 번 더 보내세요. 페이지가 새로고침되며 감사 문구가 표시됩니다.
- 알림 이메일에 답장하세요. 로그인한 사용자의 주소로 갑니다.
주의할 점
| 문제 | 해결 방법 |
|---|---|
모든 로컬 테스트에서 402 plan_required | POST가 localhost origin 없이 Ikiform에 도달했습니다. 액션의 Origin 줄을 확인하고, 내 컴퓨터의 네트워크 주소가 아니라 localhost로 앱을 여세요. |
page가 비어 있음 | no-referrer 같은 엄격한 Referrer-Policy는 같은 origin 요청에서도 헤더를 제거합니다. 기본값인 strict-origin-when-cross-origin을 쓰세요. |
| 배포 직후 Failed to find Server Action | 어떤 탭이 아직 이전 빌드를 실행 중이고, 그 액션 ID가 더 이상 존재하지 않습니다. 사용자에게 새로고침을 요청하고, 직접 호스팅한다면 모든 인스턴스에서 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY를 같게 유지하세요. |
Ikiform에서 403 forbidden | 키에 submissions:write가 없거나, 다른 워크스페이스의 키이거나, 폐기되었습니다. 새로 만드세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
질문
브라우저가 아니라 서버 액션 안에서 세션을 읽으세요. 거기서 세션이나 인증 헬퍼를 호출하고 사용자의 ID와 이메일을 POST에 추가하세요. 브라우저가 보내는 신원 정보는 사용자가 바꿀 수 있습니다.
서버 액션은 폼을 렌더링한 페이지로의 POST이므로 Referer 헤더에 그 페이지의 URL이 담깁니다. origin과 경로만 남기고 쿼리 문자열은 버리세요.
이 폼은 아닙니다. 폼은 렌더링하는 동안 쿠키나 헤더를 읽지 않습니다. 세션을 읽는 것은 액션뿐이고, 액션은 제출할 때마다 실행됩니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.