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

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

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

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

Preet SutharIkiform 創業者

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

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

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

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

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

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

Next.js のフィードバック回答が、サーバーでユーザーとページを取得する仕組みFeedbackForm評価、コメント、画面サーバーアクション現在のページへPOSTgetUser()セッションCookieを読むIkiformシークレットキー、保存アクションの状態お礼か、エラーエラー状態再ログイン、コメントは保持FormDatacookies()+ ユーザー、ページ結果セッションなしNext.js のフィードバック回答が、サーバーでユーザーとページを取得する仕組みFeedbackForm評価、コメント、画面サーバーアクション現在のページへ POSTgetUser()セッション Cookieを読むIkiformシークレットキー、保存アクションの状態お礼か、エラーエラー状態再ログイン、コメントは保持FormDatacookies()+ ユーザー、ページ結果セッションなし
  1. ユーザーがスコアを選び、場合によってはコメントを書きます。フォームはそれを画面名と一緒に送ります。
  2. サーバーアクションは、フォームのあるページへの POST として、セッション Cookie と Referer のページ付きで実行されます。
  3. getUser() がセッション Cookie を読んで確認します。有効なセッションがなければ、何も送信されません。
  4. アクションはユーザーの ID、メールアドレス、ページのアドレスを追加し、シークレットキーを付けて Ikiform に送信します。
  5. Ikiform の応答がアクションの状態になります。お礼の一文か、コメントを入力したままのエラーです。
セッションを読むのはページの描画時ではなく、フォームの送信時です。フォームの周りのページは静的なままでかまいません。

プロジェクトの準備

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

プロジェクトの構成
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses

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

プレビュー

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

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

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

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

作ってみる

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

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

lib/user.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
"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
"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
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 のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

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

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

デプロイ

ホスト対処法
Vercelプロジェクトの Environment Variables で、Production と Preview に IKIFORM_SECRET_KEY を追加します。アクションは追加の設定なしで関数として動きます。
NetlifyNetlify は独自の連携機能で Next.js をビルドします。サイトの環境変数に Functions スコープでキーを追加し、再デプロイしてください。
CloudflareCloudflare は 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 に合わせてください。

よくある質問

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