웹사이트나 앱용 피드백 폼

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

Next.js로 인앱 피드백 폼 만들기

Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.

Preet SutharIkiform 창업자

피드백을 받고 싶은 화면에 1~5점 평점이 있는 작은 클라이언트 폼을 두고 서버 액션을 가리키게 하세요. 액션은 세션 쿠키에서 로그인한 사용자를 읽고, 요청의 Referer에서 페이지를 가져와, 시크릿 키로 모든 것을 폼 백엔드에 전달합니다. 사용자가 누구인지는 브라우저가 보내지 않으므로 아무도 다른 사람인 척 피드백을 보낼 수 없습니다.

메인 가이드의 Next.js 탭은 모든 방문자를 위한 페이지 피드백 폼입니다. 이 페이지는 사람들이 로그인해 있는 앱 내부를 위한 것입니다. 거기서는 누가 쓰고 있는지, 어느 화면에 있는지 이미 알고 있으므로 폼은 두 가지만 묻고 나머지는 서버가 채웁니다. 제품 설정, 키, 허용 도메인은 Next.js 문서 가이드에 있습니다.

사용자가 로그인해 있을 때 달라지는 점

공개 피드백 폼은 브라우저가 주는 것을 받습니다. 인앱 폼은 그러면 안 됩니다. 브라우저가 user_id를 보내면 누구나 개발자 도구에서 바꿔 동료나 고객인 척 피드백을 보낼 수 있습니다. 각 값은 그 값을 믿고 맡길 수 있는 곳에서 와야 합니다.

값출처이유
rating, comment폼사용자만 알고 있음
user_id, email액션에서 읽는 세션 쿠키브라우저는 서명된 세션을 위조할 수 없음
page액션 요청의 Referer 헤더서버 액션은 자신을 렌더링한 페이지로 POST하므로 헤더에 전체 URL이 담김
screenprojects/[id]/settings처럼 페이지가 넘기는 prop모든 프로젝트의 설정 페이지를 한 이름으로 묶어 평균이 의미를 가짐

브라우저를 거치는 값은 screen 하나뿐입니다. 권한이 아니라 묶기 위한 레이블이며, 액션이 저장 전에 형식을 확인합니다.

Next.js 피드백 답변이 서버에서 사용자와 페이지 정보를 얻는 과정FeedbackForm평점,코멘트,화면서버액션현재페이지로POSTgetUser()세션 쿠키읽기Ikiform시크릿키, 저장액션상태감사 문구또는 오류오류상태다시로그인,코멘트유지FormDatacookies()+ 사용자, 페이지결과세션 없음Next.js 피드백 답변이 서버에서 사용자와 페이지 정보를 얻는 과정FeedbackForm평점, 코멘트, 화면서버 액션현재 페이지로 POSTgetUser()세션 쿠키 읽기Ikiform시크릿 키, 저장액션 상태감사 문구 또는 오류오류 상태다시 로그인, 코멘트유지FormDatacookies()+ 사용자, 페이지결과세션 없음
  1. 사용자가 점수를 고르고 코멘트를 쓸 수도 있습니다. 폼이 화면 이름과 함께 보냅니다.
  2. 서버 액션이 폼이 있는 페이지로의 POST로 실행되며, 세션 쿠키와 Referer의 페이지를 함께 받습니다.
  3. getUser()가 세션 쿠키를 읽고 확인합니다. 유효한 세션이 없으면 아무것도 POST하지 않습니다.
  4. 액션이 사용자의 ID, 이메일, 페이지 주소를 추가하고 시크릿 키로 Ikiform에 POST합니다.
  5. Ikiform의 응답이 액션 상태가 됩니다. 감사 문구이거나, 코멘트가 입력된 채로 남은 오류입니다.
세션은 페이지가 렌더링될 때가 아니라 폼을 보낼 때 읽습니다. 폼 주변 페이지는 정적으로 둘 수 있습니다.

프로젝트 설정

파일 네 개이며, 그중 하나는 아마 이미 있을 겁니다. App Router 레이아웃은 일반적인 구조입니다.

프로젝트 트리
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local

API keys에서 submissions:write 스코프로 시크릿 키를 만들고 .env.local에 넣으세요. NEXT_PUBLIC_ 접두사는 절대 붙이지 마세요. 모든 브라우저로 배포됩니다.

.env.local
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는 기존 세션 코드에 있는 것을 쓰세요. 인증 라이브러리를 쓴다면 여기서 그 라이브러리의 사용자를 반환하세요.

lib/user.ts
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;
});

서버 액션

app/feedback/actions.ts
"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은 네트워크가 느릴 때 버튼이 끝없이 돌지 않게 합니다.

폼

app/feedback/feedback-form.tsx
"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보다 많은 것을 말하며, 저장되는 값은 여전히 숫자입니다.

화면에 넣기

app/(app)/projects/[id]/settings/page.tsx
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를 추가하세요. 액션은 별도 설정 없이 함수로 실행됩니다.
NetlifyNetlify는 자체 연동으로 Next.js를 빌드합니다. 사이트의 환경 변수에 Functions 스코프로 키를 추가하고 다시 배포하세요.
CloudflareCloudflare는 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에는 실제 도메인이 담깁니다.

작동 확인하기

  1. next dev를 실행하고 로그인한 뒤 프로젝트 설정을 열어 코멘트와 함께 점수 2를 보내세요.
  2. Submissions를 여세요. 답변에 user_id, email, projects/[id]/settings로 설정된 screen, 그리고 실제 프로젝트 ID가 있고 쿼리 문자열은 없는 page가 있습니다.
  3. 다른 탭에서 로그아웃한 뒤 다시 보내세요. 폼에 로그인 메시지가 표시되고 코멘트는 입력란에 그대로 있습니다.
  4. JavaScript를 끄고 한 번 더 보내세요. 페이지가 새로고침되며 감사 문구가 표시됩니다.
  5. 알림 이메일에 답장하세요. 로그인한 사용자의 주소로 갑니다.

주의할 점

문제해결 방법
모든 로컬 테스트에서 402 plan_requiredPOST가 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_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

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