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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/feedback-form/nuxt

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

[メインガイドの Nuxt タブ](https://ikiform.com/ja/guides/feedback-form.md#form)では、誰でも使えるページのフィードバックを作ります。アカウントのある Nuxt アプリの中では、用途は狭くなり、データは豊かになります。ユーザーはサインインしていて、サーバーは相手が誰かを知り、ルーターはどの画面にいるかを知っています。このページではその版を作ります。キー、許可ドメイン、その他の製品のセットアップは [Nuxt のドキュメントガイド](https://ikiform.com/ja/docs/guides/nuxt.md)にあります。

## サインイン済みの 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 の有無にかかわらずフィードバックを処理する様子

1. ハイドレーション後は、コンポーネントがネイティブの送信を止め、$fetch でフィールドを JSON として送信します。
2. サーバールートは Origin をチェックし、セッションからユーザーを読み、スコアを検証して、Referer からページを付け加えます。
3. runtimeConfig のシークレットキーを付けて、回答を Ikiform に転送します。
4. JSON のリクエストには { sent: true } かメッセージ付きのエラーが返り、フォームはその場で更新されます。
5. ハイドレーション前は、ブラウザー自身がフォームを送信します。ルートは同じチェックと同じ送信を行います。
6. そのあと、結果をクエリ文字列に入れて 303 でページに戻し、コンポーネントがそれを表示します。

ルートはリクエストのコンテンツタイプで 2 つを見分けます。どちらの経路でも、保存される回答はまったく同じです。

## プロジェクトの準備

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

`プロジェクトの構成`:

```text
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`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
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`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

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

フォーム: In-app feedback

- **この画面の使い心地はどうですか？** (`rating`, rating, 必須)
- **どうすればもっと良くなりますか？** (`comment`, textarea)
- **ページ** (`page`, url)
- **画面** (`screen`, text)
- **ユーザー** (`user_id`, text)
- **メールアドレス** (`email`, email)

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

## 作ってみる

### サーバールート

`server/api/feedback.post.ts`:

```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`:

```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 の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`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` になります。

## 動作を確認する

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 に合わせてください。 |

## よくある質問

### Nuxt のサーバールートでログイン中のユーザーを取得するには？

nuxt-auth-utils なら、ハンドラーの中で getUserSession(event) か requireUserSession(event) を呼び、session.user を読みます。使わない場合は、サーバーミドルウェアで event.context.user を設定してそれを読みます。ユーザーをリクエストのボディから取ってはいけません。

### Nuxt のフォームは JavaScript なしで動きますか？

はい。フォームに本物の method と、サーバールートを指す action を付け、リクエストが JSON でなければルートが 303 でリダイレクトして戻すようにします。ハイドレーション後は submit を横取りして $fetch を使います。

### Nuxt は NUXT_ 環境変数をどう読みますか？

nuxt.config の runtimeConfig にキーを宣言すると、Nuxt は実行時に、NUXT_ にキーを大文字のスネークケースにしたものを付けた名前の環境変数でそれを上書きします。public の外のキーはサーバーにとどまります。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [SvelteKit のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/sveltekit.md): SvelteKit のアプリ内フィードバックフォーム。レイアウトのフォームが 1 つのフォームアクションに送信し、アクションがサーバー側で locals のユーザーとリクエストのページを追加します。
- [Astro のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/astro.md): Astro のアプリ内フィードバックフォーム。ユーザーはミドルウェアの locals から、ページはリクエストから、画面はルートのパターンから取得するアクションを作ります。
- [Next.js のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/nextjs.md): Next.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
