ウェブサイトやアプリのフィードバックフォーム

ウェブサイトやアプリのフィードバックフォーム3 分で読めます

Nuxt でアプリ内フィードバックフォームを作る

Nuxt のアプリ内フィードバックフォーム。ユーザーのセッションを読み、Referer からページを取得し、JavaScript の読み込み前でも動くサーバールートを使います。

Preet SutharIkiform 創業者

2 問のフォームを Nuxt のサーバールート server/api/feedback.post.ts に送信します。ルートは nuxt-auth-utils の getUserSession でサインイン中のユーザーを読み、リクエストの Referer からページを取得し、Zod でスコアを検証して、runtimeConfig に置いたシークレットキーですべてをフォームバックエンドに転送します。JavaScript があればフォームは $fetch で送信してその場にとどまり、なければ同じルートが結果を URL に入れてリダイレクトで戻します。

メインガイドの Nuxt タブでは、誰でも使えるページのフィードバックを作ります。アカウントのある Nuxt アプリの中では、用途は狭くなり、データは豊かになります。ユーザーはサインインしていて、サーバーは相手が誰かを知り、ルーターはどの画面にいるかを知っています。このページではその版を作ります。キー、許可ドメイン、その他の製品のセットアップは Nuxt のドキュメントガイドにあります。

サインイン済みの Nuxt アプリで変わること

公開フォームでは、何が保存されるかをブラウザーが決めます。ここでブラウザーが決めるのはスコアとコメントだけです。誰がどこから送ったかを示す情報はすべて、誰も編集できないサーバールートが付け加えます。

フィールド設定元求め方
rating, commentユーザーラジオボタンとテキストボックス
user_id, emailサーバールートgetUserSession(event) が封印されたセッション Cookie を読む
pageサーバールートReferer ヘッダー。同一オリジンの送信では、ブラウザーが完全なパスを付けて送ります
screenコンポーネント。ルートがチェックuseRoute().name。projects-id-settings のように、Nuxt がページのファイルパスから作ります

Nuxt では、ルート名がそのまま使えるグループ化のキーになります。どのプロジェクトの設定ページも projects-id-settings を共有するので、page に正確なアドレスを残しつつ、画面ごとにスコアを平均できます。

1 つの Nuxt サーバールートが、JavaScript の有無にかかわらずフィードバックを処理する様子JavaScript ありJavaScript なしFeedbackForm.vue$fetch、JSONボディfeedback.post.tsOrigin、セッション、ZodIkiformシークレットキー、保存{ sent:true }お礼がフォームを置き換える通常のフォーム送信urlencodedのボディfeedback.post.ts同じチェックIkiform同じ送信303 で戻す?feedback=sentPOST+ ユーザー、ページ200POST+ ユーザー、ページリダイレクト1 つの Nuxt サーバールートが、JavaScript の有無にかかわらずフィードバックを処理する様子JavaScript ありJavaScript なしFeedbackForm.vue$fetch、JSON ボディfeedback.post.tsOrigin、セッション、ZodIkiformシークレットキー、保存{ sent: true }お礼がフォームを置き換える通常のフォーム送信urlencoded のボディfeedback.post.ts同じチェックIkiform同じ送信303 で戻す?feedback=sentPOST+ ユーザー、ページ200POST+ ユーザー、ページリダイレクト
  1. ハイドレーション後は、コンポーネントがネイティブの送信を止め、$fetch でフィールドを JSON として送信します。
  2. サーバールートは Origin をチェックし、セッションからユーザーを読み、スコアを検証して、Referer からページを付け加えます。
  3. runtimeConfig のシークレットキーを付けて、回答を Ikiform に転送します。
  4. JSON のリクエストには { sent: true } かメッセージ付きのエラーが返り、フォームはその場で更新されます。
  5. ハイドレーション前は、ブラウザー自身がフォームを送信します。ルートは同じチェックと同じ送信を行います。
  6. そのあと、結果をクエリ文字列に入れて 303 でページに戻し、コンポーネントがそれを表示します。
ルートはリクエストのコンテンツタイプで 2 つを見分けます。どちらの経路でも、保存される回答はまったく同じです。

プロジェクトの準備

Nuxt 4 の構成を使い、アプリのコードは app/ に、サーバーのコードはルートに置きます。nuxt-auth-utils と zod をインストールします。アプリにすでに別のサインインの仕組みがあるならそれを使い、ルートでは自分のセッションからユーザーを読んでください。

プロジェクトの構成
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env

runtimeConfig にシークレットキーを空のデフォルト値で宣言します。Nuxt は実行時に NUXT_IKIFORM_SECRET_KEY から値を入れ、キーは public の下にないので、サーバー上にだけ存在します。

nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string

API keys で submissions:write スコープのキーを作ります。nuxt-auth-utils はセッション Cookie を NUXT_SESSION_PASSWORD で封印します。これは 32 文字以上でなければならず、開発時は初回の実行で自動的に書き込まれます。session.user.id に型が付くよう、ユーザーの形を教えておきます。

shared/types/auth.d.ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};

次に、フォームの Fields タブに定義を貼り付けます。最初の 2 つのフィールドがフォームで、残りはサーバールートが付け加えます。email フィールドがあると、各通知の Reply がユーザーに届きます。

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

この画面の使い心地はどうですか?

リクエストからサーバーが設定します。

プレビューでは Ikiform のバリデーターが動くので、スコアを 6 にすると、ルートが受け取るのと同じ 422 が表示されます。

作ってみる

サーバールート

server/api/feedback.post.ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

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

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
  • .post.ts の接尾辞で、このファイルは POST にだけ応答します。
  • Origin のチェックはほかの何よりも先に行います。readValidatedBody が使う readBody は urlencoded のボディも解析するので、このチェックがないと、ほかのサイトがユーザーの Cookie 付きで素のフォームをこのルートに送信できてしまいます。
  • getUserSession は例外を投げません。応答はルート自身が選びます。$fetch には 401 を、素の送信にはリダイレクトを返します。requireUserSession では、JavaScript のないユーザーに 401 のページが送られてしまいます。
  • z.coerce.number() は urlencoded の "4" を 4 に変換します。.catch("unknown") は、不正な screen があっても回答全体を拒否せず、unknown として保存します。
  • back は Referer のパスだけを残すので、リダイレクトでサイトの外に出ることはありません。

コンポーネント

app/components/FeedbackForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="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" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
  • method と action は本物の属性なので、ハイドレーション前はブラウザーがフォームをルートに送信します。Vue が動き出すと @submit.prevent が処理を引き継ぎます。
  • JSON のボディで、ルートは JSON で応答してよいとわかります。ボディは FormData から作るので、ハニーポットと screen も追加のコードなしで送られます。
  • $fetch が失敗すると、エラーの data.message にはルートの createError のメッセージが入ります。
  • 素の送信のあとは、URL の ?feedback= が初期状態を決めるので、再読み込みしたページにお礼かエラーが表示されます。
  • useUserSession は、サインインしていない訪問者からフォームを隠します。これは便宜のためでチェックではありません。チェックはルートが行います。

app/pages/projects/[id]/settings.vue など、アプリのサインイン後の部分にある任意のページに <FeedbackForm /> を置きます。Nuxt が自動でインポートします。

ハニーポット、人間確認、制限

_gotcha の入力欄はほかのフィールドと一緒に Ikiform に届き、埋まったものはスパムとして保存されます。シークレットキー付きの送信では Ikiform の人間確認がスキップされますが、ここではセッションがその役目を果たします。ルートはサインインしていない人をすべて拒否します。残るのはサインイン済みのアカウントが繰り返し送信するケースで、Ikiform はそれをサーバーのアドレス単位でしか数えられません。

サーバー側の版はシークレットキー付きで送信するので、Ikiform は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

レンダリングの選択

JavaScript なしで結果を表示するには、フォームにサーバーレンダリングが必要です。routeRules で ssr: false にしたルートでは、JavaScript が動くまでページが空なので、素の送信は起こらず、?feedback= もブラウザーでしか読まれません。それで問題はなく、フォールバックがなくなるだけです。prerender: true のルートにはフォームを置かないでください。HTML は 1 回だけ作られるので、useUserSession には誰も見えず、ハイドレーションまでフォームが表示されません。

デプロイ

ホスト対処法
VercelNitro は Vercel を検出するので、プリセットは不要です。Environment Variables に NUXT_IKIFORM_SECRET_KEY と NUXT_SESSION_PASSWORD を追加して、再デプロイしてください。
Netlifyこちらも検出されます。サイトの環境変数の設定で、Functions スコープに両方の変数を追加します。サーバールートは関数として動きます。
CloudflareWorkers 向けには cloudflare_module プリセットでビルドするか、検出される Pages にリポジトリを接続します。両方の値をシークレットとして保存し、wrangler dev 用に .dev.vars にも入れてください。

Cloudflare では、環境変数の値はリクエストの処理中にしか存在しません。ルートはハンドラーの中で useRuntimeConfig(event) を読んでおり、読むべき場所はそこです。

localhost で無料でテストする

プランがなくても、ワークスペースは localhost からのテスト送信を月 50 件まで受け付けます。判定には Origin ヘッダーを使います。Nitro のルートからの fetch の背後にはブラウザーがないので、自分で設定しない限り Origin は送られません。ルートが own.origin を渡しているのはそのためです。nuxt dev では http://localhost:3000 になってテストとして数えられ、本番では自分のドメインになります。これを省くと、ローカルのテストはすべて 402 plan_required になります。

動作を確認する

  1. nuxt dev を実行してサインインし、プロジェクトの設定ページを開いて、コメント付きでスコア 2 を送ります。
  2. Submissions で、user_id と email が自分のアカウントと一致し、screen が projects-id-settings で、page に実際のパスが入っていることを確認します。
  3. JavaScript をオフにしてもう一度送ります。ページが再読み込みされ、お礼の一文が表示され、アドレスに ?feedback=sent が付きます。
  4. curl で Origin なしに /api/feedback へ送信します。ルートは 403 を返し、Ikiform には何も届きません。
  5. セッション Cookie を消し、開いたままのページから送信します。フォームにサインインを促すメッセージが表示されます。

ハマりどころ

問題対処法
本番で自分のルートから 403 が返るプロキシがホストを書き換えるので、getRequestURL がブラウザーの Origin と一致しません。プロキシに X-Forwarded-Host を送らせてください。xForwardedHost: true がそれを読みます。
どの回答でも screen が unknown になる@nuxtjs/i18n などが付けるロケールの接尾辞付きのルート名には、パターンが拒否する文字が含まれます。正規表現を広げるか、コンポーネントで接尾辞を取り除いてください。
本番でシークレットキーが空になる変数名は NUXT_ にキーを大文字のスネークケースにしたものを付けた NUXT_IKIFORM_SECRET_KEY でなければならず、キーは runtimeConfig に存在する必要があります。
ローカルのテストで 402 plan_required が出る送信が Origin なしで出ていきました。Origin: own.origin ヘッダーを残し、アプリを localhost で開いてください。
422 validation_failed送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com