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

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

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

SvelteKit のアプリ内フィードバックフォーム。レイアウトのフォームが 1 つのフォームアクションに送信し、アクションがサーバー側で locals のユーザーとリクエストのページを追加します。

Preet SutharIkiform 創業者

サインイン後の画面を包むレイアウトにフォームを置き、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, emailhandle フックが設定する event.locals.userサーバーでセッションの Cookie から読む
pageアクションのリクエストの Referer同一オリジンのみ。それ以外は捨てる
screenhidden の入力欄に入れた page.route.idまとめるためのラベル。保存前にパターンで確認

page.route.id はルートのファイルパスで、/(app)/projects/[id]/settings のような形です。プロジェクトの設定ページはすべてこれを共有するので、画面ごとにスコアを平均したいときに好都合です。(app) のようなグループのセグメントはフォルダー構成の都合にすぎないので、アクションが取り除きます。

SvelteKit のフィードバック送信が、レイアウトのフォームから 1 つのフォームアクションを通る流れレイアウトのフォームどの画面でも、use:enhancehandleフックセッションをlocals.userへ?/sendアクションユーザーとスコアを確認Ikiformシークレットキー、保存page.form同じ画面でお礼fail(401か400)フォームの下にメッセージPOST /feedbacklocals+ ユーザー、ページnavigate: falseユーザーなしか不正なスコアSvelteKit のフィードバック送信が、レイアウトのフォームから 1 つのフォームアクションを通る流れレイアウトのフォームどの画面でも、use:enhancehandle フックセッションを locals.user へ?/send アクションユーザーとスコアを確認Ikiformシークレットキー、保存page.form同じ画面でお礼fail(401か 400)フォームの下にメッセージPOST /feedbacklocals+ ユーザー、ページnavigate: falseユーザーなしか不正なスコア
  1. ユーザーがレイアウトのフォームで画面を評価します。use:enhance がページを読み込まずに /feedback?/send へ送信します。
  2. 最初に handle フックが動き、セッションの Cookie を読んで、ユーザーを event.locals に載せます。
  3. send アクションは、ユーザーがいることと、スコアが 1〜5 の整数であることを確認します。そうでなければメッセージ付きで fail を返します。
  4. ユーザーの ID とメールアドレス、Referer から取ったページ、整形したルート ID を加えて、シークレットキーで送信します。
  5. navigate を false にした update で結果を現在の画面に適用するので、お礼の一文はユーザーがいる場所に表示されます。
JavaScript がなければ、ブラウザーは代わりに /feedback に移動し、同じ結果と戻るためのリンクが表示されます。

プロジェクトの準備

プロジェクトの構成
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
.env

API keys で、submissions:write スコープを持つシークレットキーを作ります。SvelteKit 3 では環境変数を src/env.ts で宣言します。特に指定しない限り変数は非公開で実行時に読み込まれるので、キーがブラウザーのバンドルに入ることはなく、再ビルドなしにホスト側で変更できます。開発中は .env が値を埋めます。

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.ts
import { 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 に対して行っている処理を表しています。

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
src/hooks.server.ts
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

src/routes/feedback/+page.server.ts
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 には専用のメッセージを出します。サーバー経由の場合、これはこのユーザーではなくフォーム全体が混み合っていることを意味するからです。

レイアウトのフォーム

src/lib/FeedbackForm.svelte
<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 は現在のページに属します。ユーザーが移動するとクリアされ、次の画面では新しいフォームが表示されます。
src/routes/(app)/+layout.svelte
<script lang="ts">
  import FeedbackForm from "#lib/FeedbackForm.svelte";

  let { children } = $props();
</script>

<main>{@render children()}</main>
<aside>
  <FeedbackForm />
</aside>

JavaScript がなくても同じフォームが動きます。ブラウザーは /feedback?/send に送信して /feedback に移動し、そこで結果と元のページへ戻るリンクが表示されます。

src/routes/feedback/+page.svelte
<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 となり、プランを選ぶまで保留されます。

動作を確認する

  1. vite dev を実行してサインインし、プロジェクトの設定画面を開いて、コメント付きでスコア 2 を送信します。
  2. Submissions を開きます。screen は /projects/[id]/settings、page には実際のプロジェクト ID が入り、user_id と email が自分のアカウントと一致しています。
  3. セッションの Cookie を消してもう一度送信します。フォームにサインインを促すメッセージが表示され、コメントは残ります。
  4. JavaScript をオフにして送信します。お礼の一文と画面に戻るリンクのある /feedback に移動します。
  5. 通知メールに返信します。返信はログイン中のユーザーに届きます。

ハマりどころ

問題対処法
お礼が現在の画面ではなく /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 に合わせてください。

よくある質問

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