ウェブサイトやアプリのフィードバックフォーム3 分で読めます
Nuxt でアプリ内フィードバックフォームを作る
Nuxt のアプリ内フィードバックフォーム。ユーザーのセッションを読み、Referer からページを取得し、JavaScript の読み込み前でも動くサーバールートを使います。
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 に正確なアドレスを残しつつ、画面ごとにスコアを平均できます。
- ハイドレーション後は、コンポーネントがネイティブの送信を止め、$fetch でフィールドを JSON として送信します。
- サーバールートは Origin をチェックし、セッションからユーザーを読み、スコアを検証して、Referer からページを付け加えます。
- runtimeConfig のシークレットキーを付けて、回答を Ikiform に転送します。
- JSON のリクエストには { sent: true } かメッセージ付きのエラーが返り、フォームはその場で更新されます。
- ハイドレーション前は、ブラウザー自身がフォームを送信します。ルートは同じチェックと同じ送信を行います。
- そのあと、結果をクエリ文字列に入れて 303 でページに戻し、コンポーネントがそれを表示します。
プロジェクトの準備
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
.envruntimeConfig にシークレットキーを空のデフォルト値で宣言します。Nuxt は実行時に NUXT_IKIFORM_SECRET_KEY から値を入れ、キーは public の下にないので、サーバー上にだけ存在します。
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringAPI keys で submissions:write スコープのキーを作ります。nuxt-auth-utils はセッション Cookie を NUXT_SESSION_PASSWORD で封印します。これは 32 文字以上でなければならず、開発時は初回の実行で自動的に書き込まれます。session.user.id に型が付くよう、ユーザーの形を教えておきます。
declare module "#auth-utils" {
interface User {
id: string;
email: string;
}
}
export {};次に、フォームの Fields タブに定義を貼り付けます。最初の 2 つのフィールドがフォームで、残りはサーバールートが付け加えます。email フィールドがあると、各通知の Reply がユーザーに届きます。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
6 にすると、ルートが受け取るのと同じ 422 が表示されます。作ってみる
サーバールート
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のパスだけを残すので、リダイレクトでサイトの外に出ることはありません。
コンポーネント
<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 には誰も見えず、ハイドレーションまでフォームが表示されません。
デプロイ
| ホスト | 対処法 |
|---|---|
| Vercel | Nitro は Vercel を検出するので、プリセットは不要です。Environment Variables に NUXT_IKIFORM_SECRET_KEY と NUXT_SESSION_PASSWORD を追加して、再デプロイしてください。 |
| Netlify | こちらも検出されます。サイトの環境変数の設定で、Functions スコープに両方の変数を追加します。サーバールートは関数として動きます。 |
| Cloudflare | Workers 向けには 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 になります。
動作を確認する
nuxt devを実行してサインインし、プロジェクトの設定ページを開いて、コメント付きでスコア 2 を送ります。- Submissions で、
user_idとemailが自分のアカウントと一致し、screenがprojects-id-settingsで、pageに実際のパスが入っていることを確認します。 - JavaScript をオフにしてもう一度送ります。ページが再読み込みされ、お礼の一文が表示され、アドレスに
?feedback=sentが付きます。 curlでOriginなしに/api/feedbackへ送信します。ルートは403を返し、Ikiform には何も届きません。- セッション 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 に合わせてください。 |
よくある質問
nuxt-auth-utils なら、ハンドラーの中で getUserSession(event) か requireUserSession(event) を呼び、session.user を読みます。使わない場合は、サーバーミドルウェアで event.context.user を設定してそれを読みます。ユーザーをリクエストのボディから取ってはいけません。
はい。フォームに本物の method と、サーバールートを指す action を付け、リクエストが JSON でなければルートが 303 でリダイレクトして戻すようにします。ハイドレーション後は submit を横取りして $fetch を使います。
nuxt.config の runtimeConfig にキーを宣言すると、Nuxt は実行時に、NUXT_ にキーを大文字のスネークケースにしたものを付けた名前の環境変数でそれを上書きします。public の外のキーはサーバーにとどまります。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com