ウェブサイトやアプリのフィードバックフォーム3 分で読めます
SvelteKit でアプリ内フィードバックフォームを作る
SvelteKit のアプリ内フィードバックフォーム。レイアウトのフォームが 1 つのフォームアクションに送信し、アクションがサーバー側で locals のユーザーとリクエストのページを追加します。
サインイン後の画面を包むレイアウトにフォームを置き、use:enhance で 1 つの名前付きアクション /feedback?/send に送信します。handle フックがユーザーを event.locals に載せるので、アクションがユーザーの ID とメールアドレスを自分で追加し、ページはリクエストから読みます。ブラウザーが送るのはスコア、コメント、ルート ID だけです。
メインガイドの SvelteKit タブでは、サインインの有無にかかわらず、どのページからでも +server.ts のエンドポイントに JSON を送信します。このページではアプリの内側向けの版を作ります。JavaScript の読み込み前でも動く本物のフォームアクションで、ユーザーの本人情報はサーバー側でセッションから取得します。SvelteKit 3 と Svelte 5 向けに書いています。キーと許可ドメインについては SvelteKit のドキュメントガイドにあります。
すべての画面で 1 つのアクションを使う理由
フォームアクションはページに属しますが、フィードバックフォームはレイアウトにあり、何十ものページに表示されます。各 +page.server.ts にアクションをコピーするのは面倒です。SvelteKit ではクエリ文字列で別のルートのアクションを呼べるので、どの画面も /feedback?/send に送信し、ロジックは 1 つのファイルにまとめられます。各値は、それぞれ正しい場所から取得します。
| 値 | 流入元 | 信頼 |
|---|---|---|
rating, comment | フォーム | ユーザーが伝えたいこと |
user_id, email | handle フックが設定する event.locals.user | サーバーでセッションの Cookie から読む |
page | アクションのリクエストの Referer | 同一オリジンのみ。それ以外は捨てる |
screen | hidden の入力欄に入れた page.route.id | まとめるためのラベル。保存前にパターンで確認 |
page.route.id はルートのファイルパスで、/(app)/projects/[id]/settings のような形です。プロジェクトの設定ページはすべてこれを共有するので、画面ごとにスコアを平均したいときに好都合です。(app) のようなグループのセグメントはフォルダー構成の都合にすぎないので、アクションが取り除きます。
- ユーザーがレイアウトのフォームで画面を評価します。use:enhance がページを読み込まずに /feedback?/send へ送信します。
- 最初に handle フックが動き、セッションの Cookie を読んで、ユーザーを event.locals に載せます。
- send アクションは、ユーザーがいることと、スコアが 1〜5 の整数であることを確認します。そうでなければメッセージ付きで fail を返します。
- ユーザーの ID とメールアドレス、Referer から取ったページ、整形したルート ID を加えて、シークレットキーで送信します。
- navigate を false にした update で結果を現在の画面に適用するので、お礼の一文はユーザーがいる場所に表示されます。
プロジェクトの準備
src/
app.d.ts
env.ts
hooks.server.ts
lib/
FeedbackForm.svelte
server/session.ts
routes/
(app)/+layout.svelte
feedback/
+page.server.ts
+page.svelte
.envAPI keys で、submissions:write スコープを持つシークレットキーを作ります。SvelteKit 3 では環境変数を src/env.ts で宣言します。特に指定しない限り変数は非公開で実行時に読み込まれるので、キーがブラウザーのバンドルに入ることはなく、再ビルドなしにホスト側で変更できます。開発中は .env が値を埋めます。
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});この定義をフォームの Fields タブに貼り付けます。フォームにはスコアとコメントを表示し、残りはアクションが加えます。email フィールドがあるので、各通知の Reply はユーザー宛てになります。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
6 を送ったり、定義にないフィールドを送ったりすると、アクションがメッセージに変換する 422 がプレビューに表示されます。作ってみる
ユーザーを locals に入れる
handle は、フォームアクションへの送信も含め、サーバーが受け取るすべてのリクエストで動きます。app.d.ts でユーザーの型を一度定義し、フックでセッションから読みます。readSession は、認証のコードがすでに Cookie に対して行っている処理を表しています。
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await readSession(event.cookies.get("session"));
return resolve(event);
};フォーム Action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const prerender = false;
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null, origin: string) {
try {
const url = new URL(referer ?? "");
return url.origin === origin ? url.origin + url.pathname : "";
} catch {
return "";
}
}
export const actions = {
send: async ({ request, locals, url }) => {
const data = await request.formData();
if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return fail(400, { message: "Pick a score from 1 to 5." });
}
const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
const back = pageFrom(request.headers.get("referer"), url.origin);
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 ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
rating,
comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
page: back,
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: locals.user.id,
email: locals.user.email,
_gotcha: String(data.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return fail(502, { message: "We couldn't send it. Try again in a moment." });
}
if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
}
return { sent: true, back };
},
} satisfies Actions;failは throw せずにステータスとデータを返します。SvelteKit 3 では enhance したフォームもそのステータスを受け取るので、401や400はネットワークタブにそのとおり表示されます。pageFromは、自分のオリジンからのRefererだけを残し、クエリ文字列は捨てます。export const prerender = falseは意図して置いています。アクションを持つページはプリレンダーできないので、ルートのレイアウトがデフォルトでプリレンダーする場合、これがないとビルドが失敗します。Originの行はブラウザーのオリジンを引き継ぎ(なければアプリ自身のオリジン)、リクエストの送信元を Ikiform に伝えます。これにより、下で説明するようにローカルのテストがlocalhostとして数えられます。- Ikiform からの
429には専用のメッセージを出します。サーバー経由の場合、これはこのユーザーではなくフォーム全体が混み合っていることを意味するからです。
レイアウトのフォーム
<script lang="ts">
import { enhance } from "$app/forms";
import { page } from "$app/state";
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
let sending = $state(false);
</script>
{#if page.form?.sent}
<p role="status">Thanks. We read every one.</p>
{:else}
<form
method="POST"
action="/feedback?/send"
aria-busy={sending}
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ navigate: false, refreshAll: false });
sending = false;
};
}}
>
<fieldset>
<legend>How is this screen working for you?</legend>
{#each scores as label, index}
<label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
{/each}
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
<input type="hidden" name="screen" value={page.route.id ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if page.form?.message}
<p role="alert">{page.form.message}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
</form>
{/if}- デフォルトでは、アクションが別のページにある enhance したフォームは、送信後にそのページへ移動します。
navigate: falseにするとユーザーは元の画面にとどまり、結果はここのpage.formに入ります。 refreshAll: falseにすると、成功後にすべてのload関数を再実行しません。フィードバックはページに表示される内容を何も変えないからです。- 失敗してもフォームはリセットされないので、コメントはメッセージの横の入力欄に残ります。
page.formは現在のページに属します。ユーザーが移動するとクリアされ、次の画面では新しいフォームが表示されます。
<script lang="ts">
import FeedbackForm from "#lib/FeedbackForm.svelte";
let { children } = $props();
</script>
<main>{@render children()}</main>
<aside>
<FeedbackForm />
</aside>JavaScript がなくても同じフォームが動きます。ブラウザーは /feedback?/send に送信して /feedback に移動し、そこで結果と元のページへ戻るリンクが表示されます。
<script lang="ts">
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
</script>
{#if form?.sent}
<p role="status">Thanks. We read every one.</p>
<a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
<p role="alert">{form.message}</p>
<a href="/">Back to the app</a>
{:else}
<p>Send feedback from the panel on any screen of the app.</p>
{/if}ハニーポット、人間確認、制限
hidden の _gotcha 入力欄はそのまま通り、Ikiform は埋まったものをスパムとして保存します。シークレットキー付きの送信では Ikiform の人間確認はスキップされます。ここではセッションのない人をアクションがすでに拒否しているので、人間確認で止めるはずのボットは入れません。残るのはサインイン済みのアカウントがループで送信することなので、1 つのサーバーから送信するフォーム向けのレート制限プリセットを設定してください。
サーバー側の版はシークレットキー付きで送信するので、Ikiform は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。
レンダリングとデプロイ
フォームの周りの画面は、サーバーレンダリングでもプリレンダーでも好きにできます。フォーム自体は描画時に何も必要としません。サーバーで動かす必要があるのは /feedback だけです。アダプターは vite.config.ts の sveltekit() プラグインの中で設定します。
| ホスト | アダプターとキー |
|---|---|
| Vercel | @sveltejs/adapter-vercel。Environment Variables に IKIFORM_SECRET_KEY を追加します。SvelteKit 3 ではエッジランタイムがなくなったので、アクションは Node の関数で動きます。 |
| Netlify | @sveltejs/adapter-netlify。サイトの環境変数にキーを設定します。 |
| Cloudflare | @sveltejs/adapter-cloudflare と、その peer dependency である Wrangler 4.118 以降。キーは wrangler secret put IKIFORM_SECRET_KEY などで Worker のシークレットとして保存し、上のように $app/env/private から読みます。 |
localhost で無料でテストする
プランがなくても、ワークスペースは localhost からのテスト送信を月 50 件受け取れます。Ikiform はそれを Origin ヘッダーで判定しますが、サーバーからの fetch は、設定しない限り Origin を送りません。そのためアクションはリクエストの Origin を渡しています。vite dev では http://localhost:5173 になり、送信は無料のテストとして数えられます。これを省くと、ローカルのテストは 402 plan_required となり、プランを選ぶまで保留されます。
動作を確認する
vite devを実行してサインインし、プロジェクトの設定画面を開いて、コメント付きでスコア 2 を送信します。- Submissions を開きます。
screenは/projects/[id]/settings、pageには実際のプロジェクト ID が入り、user_idとemailが自分のアカウントと一致しています。 - セッションの Cookie を消してもう一度送信します。フォームにサインインを促すメッセージが表示され、コメントは残ります。
- JavaScript をオフにして送信します。お礼の一文と画面に戻るリンクのある
/feedbackに移動します。 - 通知メールに返信します。返信はログイン中のユーザーに届きます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
お礼が現在の画面ではなく /feedback に表示される | enhance のコールバックが navigate: false なしで update() を呼んでいるか、フォームが enhance されていません。use:enhance のブロックを確認してください。 |
アクションで locals.user が常に null になる | フックが別の Cookie 名を読んでいるか、Cookie の path が /feedback をカバーしていません。SvelteKit 3 では新しい Cookie のデフォルトは / ですが、古いコードで設定した Cookie はサブパスに限定されていることがあります。 |
| 自分が所有する別のドメインからの送信が拒否される | SvelteKit は常にフォーム送信のオリジンを確認します。全員に開放するのではなく、そのオリジンを csrf.trustedOrigins に追加してください。 |
/feedback のプリレンダーに関するエラーでビルドが失敗する | アクションを持つページはプリレンダーできません。その +page.server.ts に export const prerender = false を残してください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
よくある質問
はい。フォームの action に、別のルートとアクション名を指定します(例: /feedback?/send)。use:enhance を使う場合は、update に navigate: false を渡すと、そのページに移動せず現在のページに結果が表示されます。
handle フックでセッションを読み、event.locals.user を設定します。すべてのアクションは locals を受け取るので、ブラウザーが送る内容を一切信用せずに、ユーザーの ID とメールアドレスを付けられます。
POST にはサーバーが応答しなければならないので、フォームアクションを持つページはプリレンダーできません。アクションを持つルートで prerender を false にしてください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com