웹사이트나 앱용 피드백 폼

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

Astro로 인앱 피드백 폼 만들기

Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.

Preet SutharIkiform 창업자

accept: "form"인 Astro 액션을 작성해 Zod로 평점을 검증하고, context.locals에서 로그인한 사용자를 읽고, astro:env의 시크릿으로 답변을 폼 백엔드에 전달하세요. 폼의 action을 그 액션으로 설정해 렌더링하면 JavaScript 없이 POST되고, JavaScript가 있을 때 같은 액션을 제자리에서 호출하는 짧은 스크립트를 추가하세요. 답변이 어느 페이지에 대한 것인지는 요청의 Referer에서, 화면 이름은 Astro.routePattern에서 얻습니다.

메인 가이드의 Astro 탭은 모든 방문자에게 페이지가 얼마나 유용했는지 묻습니다. 이 페이지는 로그인한 사용자가 있는 Astro 앱 안에 피드백을 넣습니다. 거기서는 누가 썼는지를 서버가 정해야 하고, 폼은 점수와 코멘트만 묻습니다. 키와 폼 설정은 Astro 문서 가이드에 자세히 있습니다.

누가 무엇을 채우는지

Astro 액션은 액션 이름을 딴 URL로 접근할 수 있는 공개 엔드포인트입니다. 누구나 조작한 본문으로 sendFeedback을 호출할 수 있으므로 액션은 전달받은 사용자 ID를 믿을 수 없습니다. 값을 누가 믿을 만하게 제공할 수 있는지에 따라 나누세요.

값설정 주체방법
rating, comment사용자액션의 Zod 스키마로 검증
user_id, email미들웨어context.locals.user, 매 요청마다 세션 쿠키에서
page요청Referer 헤더, 폼이 있는 페이지
screen페이지 렌더링hidden 입력에 담긴 /app/projects/[id]/settings 같은 Astro.routePattern

여기서 Astro가 편한 이유가 routePattern입니다. 페이지를 렌더링한 파일의 이름이므로, 모든 프로젝트의 설정 화면이 하나의 레이블로 모이고 page에는 실제 주소가 남습니다. 브라우저를 거치므로 스키마가 형식을 확인하고 이상한 값은 unknown으로 바꿉니다.

하나의 Astro 피드백 폼이 같은 액션에 도달하는 두 가지 방법JavaScript 없음스크립트 사용폼 POST현재 페이지로미들웨어쿠키에서locals.usersendFeedbackZod, 그다음fetch페이지 다시렌더링getActionResult()actions.sendFeedback()폼의FormData미들웨어같은 쿠키, 같은사용자sendFeedback같은 핸들러폼 교체{ data } 또는{ error }페이지 URL결과fetchJSON하나의 Astro 피드백 폼이 같은 액션에 도달하는 두 가지 방법JavaScript 없음스크립트 사용폼 POST현재 페이지로미들웨어쿠키에서 locals.usersendFeedbackZod, 그다음 fetch페이지 다시 렌더링getActionResult()actions.sendFeedback()폼의 FormData미들웨어같은 쿠키, 같은 사용자sendFeedback같은 핸들러폼 교체{ data } 또는 { error }페이지 URL결과fetchJSON
  1. JavaScript가 없으면 브라우저가 URL에 액션 이름을 담아 현재 페이지로 폼을 POST합니다.
  2. 미들웨어가 모든 요청에서 먼저 실행되어 로그인한 사용자를 context.locals에 넣습니다.
  3. 액션이 평점과 화면을 검증하고, 사용자와 페이지를 추가하고, 시크릿으로 Ikiform에 POST합니다.
  4. Astro가 페이지를 다시 렌더링하고, Astro.getActionResult()가 감사 문구나 오류를 보여 줍니다.
  5. JavaScript가 있으면 스크립트가 fetch로 같은 액션을 호출합니다. 미들웨어와 핸들러는 그대로 실행됩니다.
  6. 스크립트가 새로고침 없이 폼을 감사 문구로 바꾸거나, 폼 아래에 오류를 씁니다.
핸들러 하나, 전송 방식 둘. 두 요청 모두 페이지에서 오므로 둘 다 세션 쿠키와 Referer의 페이지를 담습니다.

프로젝트 설정

프로젝트 트리
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env

시크릿은 astro:env 스키마에 선언하세요. 그러면 Astro는 시크릿 없이 시작하지 않고, import에 타입을 붙이고, 클라이언트 번들에서 제외합니다. 어떤 서버 어댑터든 되며, 예시는 Node입니다.

astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

로컬 작업용으로 .env에 IKIFORM_SECRET_KEY=ik_sk_...를 넣으세요. 키는 API keys에서 submissions:write 스코프로 만드세요. 그런 다음 폼의 Fields 탭에 이 정의를 붙여 넣으세요.

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

이 화면은 어떠신가요?

서버가 요청에서 설정합니다.

액션의 스키마가 이 제한을 그대로 반영하므로, 잘못된 입력 대부분은 요청 비용이 들기 전에 거부됩니다.

만들기

locals에 사용자 넣기

미들웨어는 모든 페이지와 액션보다 먼저 실행됩니다. 거기서 인증 코드가 제공하는 방법으로 세션을 한 번 읽으면 모든 핸들러가 결과를 받습니다. readSession이 그 코드를 대신합니다. Astro 자체 세션도 됩니다. 대신 액션에서 context.session을 읽으세요.

src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

액션

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
  • 핸들러의 두 번째 인자는 액션 컨텍스트입니다. locals, request, cookies는 있지만 redirect는 없으므로, 세션이 없으면 폼이 표시할 수 있는 UNAUTHORIZED 오류가 됩니다.
  • screen의 .catch("unknown")은 위조된 레이블 때문에 답변 전체를 실패시키지 않고 unknown으로 바꿉니다.
  • 빈 폼 입력은 form 액션에 null로 전달되므로 comment와 허니팟이 nullish입니다.
  • 폼 POST와 스크립트 호출 모두 페이지가 보내므로 어느 쪽이든 Referer에 페이지 주소가 담깁니다. pageFrom은 origin과 경로를 남깁니다.
  • 로컬 테스트가 localhost로 집계되도록 Origin을 전달합니다. 아래에서 더 설명합니다.

폼 컴포넌트

src/components/FeedbackForm.astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </fieldset>
    <label for="feedback-comment">What would make it better?</label>
    <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
  • action={actions.sendFeedback} 덕분에 JavaScript가 전혀 없어도 폼이 동작합니다. Astro가 POST를 처리하고 결과와 함께 페이지를 렌더링합니다.
  • isInputError는 fields가 있는 스키마 실패와, 세션 메시지처럼 핸들러가 던진 오류를 구분합니다.
  • 스크립트는 astro:actions에서 actions를 import해 폼 데이터로 같은 핸들러를 호출합니다. Astro가 번들링하므로 추가 파일이 없습니다.
  • JavaScript 없이 제출하면 페이지가 POST 결과로 남아, 새로고침 시 다시 보낼지 묻습니다. 그게 거슬린다면 미들웨어의 getActionContext로 결과를 저장하고 리디렉션할 수 있습니다.

페이지

src/pages/app/projects/[id]/settings.astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>

렌더링 모드

폼 액션은 요청 시 렌더링되는 페이지에서만 동작합니다. 사전 렌더링된 페이지는 한 번만 빌드되므로 POST할 요청이 없습니다. 기본 정적 출력이라면 앱 페이지마다 export const prerender = false를 표시하고, 사이트 대부분이 로그인 뒤에 있다면 output: "server"를 설정하세요. 사전 렌더링 페이지에서도 빌드 시 미들웨어는 실행되지만 쿠키가 없으므로 거기서 locals.user는 null입니다.

허니팟, 사람 확인, 한도

hidden _gotcha 입력은 그대로 전달되고, 값이 채워지면 스팸으로 저장됩니다. 시크릿 키 POST에서는 사람 확인이 실행되지 않으며, 로그인 요구가 대신 그 역할을 합니다. Astro는 기본적으로 요청 시 렌더링 페이지로의 폼 POST에서 Origin을 확인하므로 다른 사이트가 사용자의 브라우저에서 폼을 제출할 수 없습니다.

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

배포

호스트어댑터시크릿
Vercel@astrojs/vercel프로젝트의 환경 변수에 IKIFORM_SECRET_KEY를 추가하세요. Astro 세션을 쓴다면 스토리지 드라이버를 설정하세요. Vercel에는 기본값이 없습니다.
Netlify@astrojs/netlify사이트의 환경 변수에 추가하세요. 세션의 기본값은 Netlify Blobs입니다.
Cloudflare@astrojs/cloudflarenpx wrangler secret put IKIFORM_SECRET_KEY를 실행하고 로컬에서는 .dev.vars에 넣으세요. astro:env/server와 cloudflare:workers의 env 모두 이를 읽습니다. 세션은 SESSION이라는 KV 바인딩을 씁니다.

Astro 6부터 Cloudflare 어댑터에서 Astro.locals.runtime.env가 사라졌습니다. 거기서 키를 읽던 코드는 위의 astro:env import가 필요합니다.

무료 로컬 테스트

플랜이 없는 워크스페이스는 localhost에서 월 50건의 무료 테스트 제출을 받으며, Ikiform은 이를 Origin 헤더로 알아봅니다. 브라우저는 astro dev에 http://localhost:4321을 보내지만, 액션이 Ikiform으로 보내는 fetch에는 origin이 없습니다. Origin 줄이 브라우저의 값을 전달하므로 로컬 테스트는 무료이고, 프로덕션에서는 헤더가 내 도메인이 됩니다.

작동 확인하기

  1. astro dev를 실행하고 로그인한 뒤 프로젝트 설정 페이지를 열어 코멘트와 함께 2점을 보내세요.
  2. Submissions에서 screen이 /app/projects/[id]/settings이고, page에 실제 ID가 있고, user_id와 email이 내 것인지 확인하세요.
  3. JavaScript를 끄고 다시 제출하세요. 페이지가 새로고침되며 감사 문구가 표시됩니다.
  4. 세션 쿠키를 삭제하고 제출하세요. 폼에 로그인 메시지가 표시됩니다.
  5. 알림 이메일에 답장하세요. 로그인한 사용자에게 갑니다.

주의할 점

문제해결 방법
폼은 POST되지만 페이지에 결과가 없음페이지가 사전 렌더링되었습니다. export const prerender = false를 추가하거나 output: "server"로 바꾸세요.
빌드 시작 시 IKIFORM_SECRET_KEY is missingastro:env/server를 import할 때마다 시크릿을 검증합니다. 빌드에 변수를 제공하거나, 빌드에는 자리 표시 값을 주고 런타임에 실제 값을 주세요.
로컬 테스트에서 402 plan_required전달된 Origin이 비어 있거나 localhost가 아닙니다. localhost:4321로 앱을 열고 Origin 줄을 유지하세요.
프로덕션에서 locals.user가 항상 null환경마다 쿠키 이름이나 도메인이 다르거나, 페이지가 아직 사전 렌더링되고 있습니다. 미들웨어에서 context.cookies.has("session")을 로그로 남겨 보세요.
422 validation_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

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