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

> Next.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。

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

フィードバックがほしい画面に 1〜5 の評価がある小さなクライアントフォームを置き、サーバーアクションに向けます。アクションはセッション Cookie からログイン中のユーザーを読み、リクエストの `Referer` からページを取り、シークレットキーを付けてすべてをフォームバックエンドに転送します。ブラウザーはユーザーが誰かを送らないので、誰もほかの人になりすましてフィードバックを送れません。

[メインガイドの Next.js タブ](https://ikiform.com/ja/guides/feedback-form.md#form)は、どの訪問者にも向けたページのフィードバックフォームです。このページは、ユーザーがログインしているアプリの内側向けです。そこでは誰が書いていて、どの画面にいるかがすでにわかっているので、フォームは 2 つだけ尋ね、残りはサーバーが埋めます。プロダクトの設定、キー、許可ドメインについては [Next.js のドキュメントガイド](https://ikiform.com/ja/docs/guides/nextjs.md)にあります。

## ユーザーがログインしていると何が変わるか

公開のフィードバックフォームは、ブラウザーが渡すものを受け取ります。アプリ内のフォームはそうすべきではありません。ブラウザーが `user_id` を送ると、誰でも開発者ツールで書き換えて、同僚や顧客としてフィードバックを送れてしまいます。それぞれの値は、信頼できる場所から取るべきです。

| 値 | 取得元 | そこから取る理由 |
| --- | --- | --- |
| `rating`, `comment` | フォーム | 本人しか知らない |
| `user_id`, `email` | アクションで読むセッション Cookie | ブラウザーは署名付きのセッションを偽造できない |
| `page` | アクションのリクエストの `Referer` ヘッダー | サーバーアクションはそれを描画したページに送信するので、ヘッダーに完全な URL が入る |
| `screen` | `projects/[id]/settings` のような、ページが渡す prop | すべてのプロジェクトの設定ページを 1 つの名前でまとめるので、平均に意味が出る |

ブラウザーを通る値は `screen` だけです。これはグループ分けのためのラベルで、権限ではありません。アクションは保存する前にその形式を確認します。

図: Next.js のフィードバック回答が、サーバーでユーザーとページを取得する仕組み

1. ユーザーがスコアを選び、場合によってはコメントを書きます。フォームはそれを画面名と一緒に送ります。
2. サーバーアクションは、フォームのあるページへの POST として、セッション Cookie と Referer のページ付きで実行されます。
3. getUser() がセッション Cookie を読んで確認します。有効なセッションがなければ、何も送信されません。
4. アクションはユーザーの ID、メールアドレス、ページのアドレスを追加し、シークレットキーを付けて Ikiform に送信します。
5. Ikiform の応答がアクションの状態になります。お礼の一文か、コメントを入力したままのエラーです。

セッションを読むのはページの描画時ではなく、フォームの送信時です。フォームの周りのページは静的なままでかまいません。

## プロジェクトの準備

ファイルは 4 つで、うち 1 つはおそらくすでにあります。App Router のレイアウトは通常どおりです。

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

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

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses
```

フォームの **Fields** タブにこの定義を貼り付けます。ユーザーに見えるのは最初の 2 つのフィールドで、残りの 4 つはサーバーが設定します。`email` フィールドがあるので、通知メールで **Reply** を押すとそのままユーザーに返信できます。

フォーム: In-app feedback

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

スコアに `0` を入れたり、知らないフィールド名を使ったりすると、プレビューはアクションが受け取るのと同じ `422` を返します。

## 作ってみる

### ユーザーを 1 か所で読み込む

Next.js はデータアクセス層を推奨しています。セッションを読んで確認するサーバー専用の関数を 1 つ用意し、すべてのアクションで使う方法です。React の `cache` を使うと、1 つのリクエスト内で何度呼んでも Cookie の読み込みは 1 回で済みます。`decrypt` はセッションのコードにすでにあるものを使ってください。認証ライブラリを使っているなら、ここでそのユーザーを返します。

`lib/user.ts`:

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

```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` のオリジンとパスだけを残します。アプリのクエリ文字列には、フィードバックの受信箱に入れたくない ID やトークンがよく含まれています。
- `screen` はパスに似た短いパターンに一致する必要があります。それ以外は `unknown` として保存されるので、細工した送信で列をゴミで埋めることはできません。
- `Origin` はブラウザーのオリジンをそのまま渡します。本番では自分のドメイン、手元のマシンでは `localhost` になり、後述のとおり、これでローカルのテストが無料になります。
- `AbortSignal.timeout` で、ネットワークが遅いときにボタンが回り続けるのを防ぎます。

### フォーム

`app/feedback/feedback-form.tsx`:

```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 はこれを本物のフォーム送信として描画するので、JavaScript の読み込み前にクリックしたユーザーのフィードバックも送られます。
- `useActionState` の `pending` で、アクションの実行中はボタンを無効にし、二重送信を防ぎます。
- React はアクションのあとにフォームをリセットします。`defaultValue={state.comment}` でエラー時にコメントを戻すので、誰も 2 回入力せずに済みます。
- ラベルでスコアに名前を付けます。`Broken` や `Great` は `1` や `5` より多くを伝え、保存される値は数値のままです。

### 画面に置く

`app/(app)/projects/[id]/settings/page.tsx`:

```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` として渡します。これで、どのプロジェクトの設定ページも 1 つの名前にまとまり、実際の ID を含む完全なアドレスは `page` に残ります。

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

非表示の `_gotcha` 入力欄はそのまま転送されます。Ikiform は埋まったものをスパムとして保存します。シークレットキー付きの送信はスキップされるので人間確認は動きませんが、ここでは問題ありません。アクションはセッションのない人を拒否し、ボットはセッションを持っていないからです。残るリスクは、サインイン済みの 1 つのアカウントがループで送信し続けることです。Ikiform のレート制限からはサーバーのアドレスしか見えないので、それに合ったプリセットを選んでください。

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

## レンダリングとキャッシュ

フォームはレンダリング時に何も読みません。`cookies()` も `headers()` も呼ばないのでページは動的にならず、Cache Components がオンでも `<Suspense>` の境界なしで静的なシェルに入れられます。リクエストに触れるのはアクションだけで、アクションは常にリクエストごとに実行されます。ほかの理由でページがすでにセッションを読んでいる場合も、何も変わりません。

## デプロイ

| ホスト | 対処法 |
| --- | --- |
| Vercel | プロジェクトの **Environment Variables** で、Production と Preview に `IKIFORM_SECRET_KEY` を追加します。アクションは追加の設定なしで関数として動きます。 |
| Netlify | Netlify は独自の連携機能で Next.js をビルドします。サイトの環境変数に Functions スコープでキーを追加し、再デプロイしてください。 |
| Cloudflare | Cloudflare は 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` はローカルのテストとして数えられ、本番の送信には本当のドメインが付きます。

## 動作を確認する

1. `next dev` を実行してサインインし、プロジェクトの設定を開いて、コメント付きでスコア 2 を送信します。
2. **Submissions** を開きます。回答には `user_id`、`email`、`projects/[id]/settings` が入った `screen`、実際のプロジェクト ID を含みクエリ文字列のない `page` があります。
3. 別のタブでサインアウトしてから、もう一度送信します。フォームにはサインインを求めるメッセージが表示され、コメントは入力欄に残っています。
4. JavaScript を無効にして、もう一度送信します。ページが再読み込みされ、お礼の一文が表示されます。
5. 通知メールに返信します。サインインしているユーザーのアドレスに届きます。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| ローカルのテストがすべて `402 plan_required` になる | 送信が `localhost` のオリジンなしで Ikiform に届いています。アクションの `Origin` の行を確認し、マシンのネットワークアドレスではなく `localhost` でアプリを開いてください。 |
| `page` が空 | `no-referrer` のような厳しい `Referrer-Policy` は、同一オリジンのリクエストでもヘッダーを削除します。デフォルトの `strict-origin-when-cross-origin` を使ってください。 |
| デプロイ直後に Failed to find Server Action が出る | タブがまだ前のビルドを動かしていて、そのアクション ID がもう存在しません。ユーザーに再読み込みを頼み、セルフホストする場合はすべてのインスタンスで `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` を同じにしてください。 |
| Ikiform から `403 forbidden` | キーに `submissions:write` がないか、別のワークスペースのものか、取り消されています。新しいキーを作成してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### Next.js でログイン中のユーザーをフィードバックに紐づけるには？

セッションはブラウザーではなく、サーバーアクションの中で読みます。そこでセッションや認証のヘルパーを呼び、ユーザーの ID とメールアドレスを送信に加えます。本人確認についてブラウザーが送るものは、ユーザーが変更できてしまいます。

### Next.js のサーバーアクションは、フィードバックがどのページから来たかをどう知るのですか？

サーバーアクションはフォームを描画しているページへの POST なので、Referer ヘッダーにそのページの URL が入っています。オリジンとパスを残し、クエリ文字列は捨ててください。

### フィードバックフォームで Next.js のページは動的になりますか？

このフォームならなりません。フォームはレンダリング中に Cookie もヘッダーも読みません。セッションを読むのはアクションだけで、送信のたびに実行されます。

### Ikiform は無料ですか？

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

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [React フィードバックウィジェット](https://ikiform.com/ja/guides/feedback-form/react-feedback-widget.md): React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- [React Router のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/react-router.md): React Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。
- [TanStack Start のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/tanstack-start.md): TanStack Start のアプリ内フィードバックフォーム。セッションのミドルウェア付きの POST サーバー関数、文脈としてのルート ID、リクエストヘッダーから読むページを組み合わせます。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
