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

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

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

サインイン後の画面を包むレイアウトにフォームを置き、`use:enhance` で 1 つの名前付きアクション `/feedback?/send` に送信します。`handle` フックがユーザーを `event.locals` に載せるので、アクションがユーザーの ID とメールアドレスを自分で追加し、ページはリクエストから読みます。ブラウザーが送るのはスコア、コメント、ルート ID だけです。

[メインガイドの SvelteKit タブ](https://ikiform.com/ja/guides/feedback-form.md#form)では、サインインの有無にかかわらず、どのページからでも `+server.ts` のエンドポイントに JSON を送信します。このページではアプリの内側向けの版を作ります。JavaScript の読み込み前でも動く本物のフォームアクションで、ユーザーの本人情報はサーバー側でセッションから取得します。SvelteKit 3 と Svelte 5 向けに書いています。キーと許可ドメインについては [SvelteKit のドキュメントガイド](https://ikiform.com/ja/docs/guides/sveltekit.md)にあります。

## すべての画面で 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)` のようなグループのセグメントはフォルダー構成の都合にすぎないので、アクションが取り除きます。

図: SvelteKit のフィードバック送信が、レイアウトのフォームから 1 つのフォームアクションを通る流れ

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 に移動し、同じ結果と戻るためのリンクが表示されます。

## プロジェクトの準備

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

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

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

この定義をフォームの **Fields** タブに貼り付けます。フォームにはスコアとコメントを表示し、残りはアクションが加えます。`email` フィールドがあるので、各通知の **Reply** はユーザー宛てになります。

フォーム: In-app feedback

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

スコアに `6` を送ったり、定義にないフィールドを送ったりすると、アクションがメッセージに変換する `422` がプレビューに表示されます。

## 作ってみる

### ユーザーを locals に入れる

`handle` は、フォームアクションへの送信も含め、サーバーが受け取るすべてのリクエストで動きます。`app.d.ts` でユーザーの型を一度定義し、フックでセッションから読みます。`readSession` は、認証のコードがすでに Cookie に対して行っている処理を表しています。

`src/app.d.ts`:

```ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
```

`src/hooks.server.ts`:

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

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

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

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

```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 の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`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 に合わせてください。 |

## よくある質問

### SvelteKit のフォームから別のルートのアクションを呼べますか？

はい。フォームの action に、別のルートとアクション名を指定します（例: /feedback?/send）。use:enhance を使う場合は、update に navigate: false を渡すと、そのページに移動せず現在のページに結果が表示されます。

### SvelteKit のフォームアクションでログイン中のユーザーを取得するには？

handle フックでセッションを読み、event.locals.user を設定します。すべてのアクションは locals を受け取るので、ブラウザーが送る内容を一切信用せずに、ユーザーの ID とメールアドレスを付けられます。

### フィードバックのルートでプリレンダーのビルドが壊れるのはなぜですか？

POST にはサーバーが応答しなければならないので、フォームアクションを持つページはプリレンダーできません。アクションを持つルートで prerender を false にしてください。

### Ikiform は無料ですか？

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

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [Nuxt のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/nuxt.md): Nuxt のアプリ内フィードバックフォーム。ユーザーのセッションを読み、Referer からページを取得し、JavaScript の読み込み前でも動くサーバールートを使います。
- [Astro のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/astro.md): Astro のアプリ内フィードバックフォーム。ユーザーはミドルウェアの locals から、ページはリクエストから、画面はルートのパターンから取得するアクションを作ります。
- [Next.js のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/nextjs.md): Next.js のアプリ内フィードバックフォーム。サーバーアクションがセッションを読み、リクエストからページを取り、ユーザー情報を付けて評価を転送します。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
