ウェブサイトやアプリのフィードバックフォーム3 分で読めます
Next.js でアプリ内フィードバックフォームを作る
Next.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。
フィードバックがほしい画面に 1〜5 の評価がある小さなクライアントフォームを置き、サーバーアクションに向けます。アクションはセッション Cookie からログイン中のユーザーを読み、リクエストの Referer からページを取り、シークレットキーを付けてすべてをフォームバックエンドに転送します。ブラウザーはユーザーが誰かを送らないので、誰もほかの人になりすましてフィードバックを送れません。
メインガイドの Next.js タブは、どの訪問者にも向けたページのフィードバックフォームです。このページは、ユーザーがログインしているアプリの内側向けです。そこでは誰が書いていて、どの画面にいるかがすでにわかっているので、フォームは 2 つだけ尋ね、残りはサーバーが埋めます。プロダクトの設定、キー、許可ドメインについては Next.js のドキュメントガイドにあります。
ユーザーがログインしていると何が変わるか
公開のフィードバックフォームは、ブラウザーが渡すものを受け取ります。アプリ内のフォームはそうすべきではありません。ブラウザーが user_id を送ると、誰でも開発者ツールで書き換えて、同僚や顧客としてフィードバックを送れてしまいます。それぞれの値は、信頼できる場所から取るべきです。
| 値 | 取得元 | そこから取る理由 |
|---|---|---|
rating, comment | フォーム | 本人しか知らない |
user_id, email | アクションで読むセッション Cookie | ブラウザーは署名付きのセッションを偽造できない |
page | アクションのリクエストの Referer ヘッダー | サーバーアクションはそれを描画したページに送信するので、ヘッダーに完全な URL が入る |
screen | projects/[id]/settings のような、ページが渡す prop | すべてのプロジェクトの設定ページを 1 つの名前でまとめるので、平均に意味が出る |
ブラウザーを通る値は screen だけです。これはグループ分けのためのラベルで、権限ではありません。アクションは保存する前にその形式を確認します。
- ユーザーがスコアを選び、場合によってはコメントを書きます。フォームはそれを画面名と一緒に送ります。
- サーバーアクションは、フォームのあるページへの POST として、セッション Cookie と Referer のページ付きで実行されます。
- getUser() がセッション Cookie を読んで確認します。有効なセッションがなければ、何も送信されません。
- アクションはユーザーの ID、メールアドレス、ページのアドレスを追加し、シークレットキーを付けて Ikiform に送信します。
- Ikiform の応答がアクションの状態になります。お礼の一文か、コメントを入力したままのエラーです。
プロジェクトの準備
ファイルは 4 つで、うち 1 つはおそらくすでにあります。App Router のレイアウトは通常どおりです。
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localAPI keys で submissions:write スコープを持つシークレットキーを作成し、.env.local に入れます。NEXT_PUBLIC_ を先頭に付けてはいけません。付けると、すべてのブラウザーに送られてしまいます。
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 はセッションのコードにすでにあるものを使ってください。認証ライブラリを使っているなら、ここでそのユーザーを返します。
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;
});サーバーアクション
"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で、ネットワークが遅いときにボタンが回り続けるのを防ぎます。
フォーム
"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より多くを伝え、保存される値は数値のままです。
画面に置く
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 を追加します。アクションは追加の設定なしで関数として動きます。 |
| 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 はローカルのテストとして数えられ、本番の送信には本当のドメインが付きます。
動作を確認する
next devを実行してサインインし、プロジェクトの設定を開いて、コメント付きでスコア 2 を送信します。- Submissions を開きます。回答には
user_id、email、projects/[id]/settingsが入ったscreen、実際のプロジェクト ID を含みクエリ文字列のないpageがあります。 - 別のタブでサインアウトしてから、もう一度送信します。フォームにはサインインを求めるメッセージが表示され、コメントは入力欄に残っています。
- JavaScript を無効にして、もう一度送信します。ページが再読み込みされ、お礼の一文が表示されます。
- 通知メールに返信します。サインインしているユーザーのアドレスに届きます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
ローカルのテストがすべて 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 に合わせてください。 |
よくある質問
セッションはブラウザーではなく、サーバーアクションの中で読みます。そこでセッションや認証のヘルパーを呼び、ユーザーの ID とメールアドレスを送信に加えます。本人確認についてブラウザーが送るものは、ユーザーが変更できてしまいます。
サーバーアクションはフォームを描画しているページへの POST なので、Referer ヘッダーにそのページの URL が入っています。オリジンとパスを残し、クエリ文字列は捨ててください。
このフォームならなりません。フォームはレンダリング中に Cookie もヘッダーも読みません。セッションを読むのはアクションだけで、送信のたびに実行されます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com