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

> Astro のアプリ内フィードバックフォーム。ユーザーはミドルウェアの locals から、ページはリクエストから、画面はルートのパターンから取得するアクションを作ります。

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

`accept: "form"` を指定した Astro のアクションを書き、Zod で評価を検証し、`context.locals` からサインイン中のユーザーを読み、`astro:env` のシークレットを使って回答をフォームバックエンドに転送します。フォームの `action` にそのアクションを指定して描画すれば JavaScript なしでも送信でき、JavaScript があるときは同じアクションをその場で呼ぶ短いスクリプトを加えます。回答の対象ページはリクエストの `Referer` から取り、`Astro.routePattern` が画面の名前になります。

[メインガイドの Astro タブ](https://ikiform.com/ja/guides/feedback-form.md#form)では、どの訪問者にもページがどれだけ役に立ったかを尋ねます。このページでは、サインインしたユーザーがいる Astro アプリの中にフィードバックを置きます。誰が書いたかはサーバーが決め、フォームが尋ねるのはスコアとコメントだけです。キーとフォームの設定については [Astro のドキュメントガイド](https://ikiform.com/ja/docs/guides/astro.md)に詳しくあります。

## 誰が何を埋めるか

Astro のアクションは公開エンドポイントで、アクション名の付いた URL から呼び出せます。誰でも細工したボディで `sendFeedback` を呼べるので、アクションは渡されたユーザー ID を信じられません。値は、誰なら信頼して任せられるかで分けます。

| 値 | 設定元 | 求め方 |
| --- | --- | --- |
| `rating`, `comment` | ユーザー | アクションの Zod スキーマで検証 |
| `user_id`, `email` | ミドルウェア | `context.locals.user`。毎回のリクエストのセッション Cookie から |
| `page` | リクエスト | `Referer` ヘッダー。フォームが置かれたページ |
| `screen` | ページの描画 | 非表示の入力欄に入れた `Astro.routePattern`（`/app/projects/[id]/settings` など） |

Astro がここで扱いやすいのは `routePattern` のおかげです。ページを描画したファイルを指すので、どのプロジェクトの設定画面も 1 つのラベルにまとまり、`page` には実際のアドレスが残ります。ブラウザーを経由して届くので、スキーマが形を確認し、おかしなものは `unknown` に置き換えます。

図: 1 つの Astro フィードバックフォームが同じアクションに届く 2 つの経路

1. JavaScript がなければ、ブラウザーは URL にアクション名を付けて、フォームを現在のページに送信します。
2. 毎回のリクエストで最初にミドルウェアが動き、サインイン中のユーザーを context.locals に入れます。
3. アクションは評価と画面を検証し、ユーザーとページを加えて、シークレット付きで Ikiform に送信します。
4. Astro がページをもう一度描画し、Astro.getActionResult() がお礼の一文かエラーを表示します。
5. JavaScript があれば、スクリプトが同じアクションを fetch で呼びます。ミドルウェアとハンドラーはまったく同じように動きます。
6. スクリプトはフォームをお礼の一文に差し替えるか、その下にエラーを書き込みます。再読み込みはありません。

1 つのハンドラーに 2 つの送り方。どちらのリクエストもページから来るので、どちらにもセッション Cookie と、Referer にページが入っています。

## プロジェクトの準備

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

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

シークレットを `astro:env` のスキーマで宣言します。すると Astro はそれがないと起動せず、インポートに型を付け、クライアントのバンドルから除外します。サーバーアダプターは何でも使えます。ここでは Node を示します。

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

ローカルでの作業用に、`.env` に `IKIFORM_SECRET_KEY=ik_sk_...` を入れます。キーは **API keys** で `submissions:write` スコープを付けて作成します。次に、フォームの **Fields** タブにこの定義を貼り付けます。

フォーム: In-app feedback

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

アクションのスキーマはこれらの制限をなぞっているので、不正な入力のほとんどはリクエストを消費する前に拒否されます。

## 作ってみる

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

ミドルウェアはすべてのページとアクションの前に動きます。そこで認証のコードが提供する方法でセッションを 1 回読めば、すべてのハンドラーがその結果を受け取れます。`readSession` はそのコードの代わりです。Astro 自身のセッションも使えます。その場合はアクションで `context.session` を読んでください。

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

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

### Action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

function pageFrom(referer: string | null) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const 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 ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- ハンドラーの第 2 引数はアクションのコンテキストです。`locals`、`request`、`cookies` はありますが `redirect` はないので、セッションがなければフォームで表示できる `UNAUTHORIZED` エラーになります。
- `screen` の `.catch("unknown")` は、偽造されたラベルで回答全体を失敗させず、`unknown` に置き換えます。
- 空のフォーム入力は `form` アクションに `null` として届きます。そのため `comment` とハニーポットは `nullish` にしています。
- フォームの送信もスクリプトからの呼び出しもページから送られるので、どちらでも `Referer` にページのアドレスが入っています。`pageFrom` はオリジンとパスを残します。
- ローカルのテストが `localhost` として数えられるよう、`Origin` を引き継ぎます。詳しくは後述します。

### フォームコンポーネント

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </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={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` で、フォームは JavaScript がまったくなくても動きます。Astro が送信を処理し、結果付きでページを描画します。
- `isInputError` で、`fields` を持つスキーマの失敗と、セッションのメッセージなどハンドラーが投げたエラーを区別します。
- スクリプトは `astro:actions` から `actions` をインポートし、フォームのデータで同じハンドラーを呼びます。Astro がバンドルするので、追加のファイルはありません。
- JavaScript なしで送信すると、ページは POST の結果のままになり、再読み込みすると再送信を求められます。気になるなら、ミドルウェアの `getActionContext` で結果を保存してリダイレクトできます。

### ページ

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## レンダリングモード

フォームアクションが動くのは、オンデマンドで描画されるページだけです。プリレンダーされたページは 1 回だけビルドされるので、送信先のリクエストがありません。デフォルトの静的出力なら、アプリの各ページに `export const prerender = false` を付けるか、サイトの大半がサインインの向こうにあるなら `output: "server"` を設定してください。プリレンダーされたページでもビルド時にミドルウェアは動きますが、そこには Cookie がないので、`locals.user` は `null` になります。

## ハニーポット、人間確認、制限

非表示の `_gotcha` 入力欄はそのまま渡され、埋まったものはスパムとして保存されます。シークレットキー付きの送信では人間確認は動きませんが、サインイン必須であることがその役目を果たします。Astro はデフォルトでオンデマンドのページへのフォーム送信の `Origin` をチェックするので、別のサイトがユーザーのブラウザーでフォームを送信することはできません。

サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## デプロイ

| ホスト | アダプター | シークレット |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | プロジェクトの環境変数に `IKIFORM_SECRET_KEY` を追加します。Astro のセッションを使うなら、ストレージドライバーを設定してください。Vercel にはデフォルトがありません。 |
| Netlify | `@astrojs/netlify` | サイトの環境変数に追加します。セッションのデフォルトは Netlify Blobs です。 |
| Cloudflare | `@astrojs/cloudflare` | `npx wrangler secret put IKIFORM_SECRET_KEY` を実行し、ローカルでは `.dev.vars` に入れます。`astro:env/server` からも、`cloudflare:workers` の `env` からも読めます。セッションは `SESSION` という名前の KV バインディングを使います。 |

Astro 6 以降、Cloudflare アダプターから `Astro.locals.runtime.env` はなくなりました。そこからキーを読んでいたコードは、上の `astro:env` のインポートが必要です。

## 無料のローカルテスト

プランのないワークスペースは `localhost` からのテスト送信を月 50 件まで無料で受け取れ、Ikiform はそれを `Origin` ヘッダーで見分けます。ブラウザーは `astro dev` に `http://localhost:4321` を送りますが、アクション自身の Ikiform への `fetch` はオリジンを送りません。`Origin` の行がブラウザーのものを転送するのでローカルのテストは無料になり、本番ではヘッダーが自分のドメインになります。

## 動作を確認する

1. `astro dev` を実行してサインインし、プロジェクトの設定ページを開いて、コメント付きで 2 を送信します。
2. **Submissions** で、`screen` が `/app/projects/[id]/settings` であること、`page` に実際の ID があること、`user_id` と `email` が自分のものであることを確認します。
3. JavaScript をオフにして、もう一度送信します。ページが再読み込みされ、お礼の一文が表示されます。
4. セッション Cookie を削除して送信します。フォームにサインインを求めるメッセージが表示されます。
5. 通知メールに返信します。宛先はサインイン中のユーザーです。

## ハマりどころ

| 問題 | 対処法 |
| --- | --- |
| フォームは送信されるが、ページに結果が表示されない | ページがプリレンダーされています。`export const prerender = false` を付けるか、`output: "server"` に切り替えてください。 |
| ビルド開始時に `IKIFORM_SECRET_KEY is missing` が出る | シークレットは `astro:env/server` がインポートされるたびに検証されます。ビルドに変数を渡すか、ビルドにはプレースホルダーを渡して実行時に本当の値を渡してください。 |
| ローカルのテストで `402 plan_required` が出る | 転送された `Origin` が空か、`localhost` ではありません。`localhost:4321` でアプリを開き、`Origin` の行を残してください。 |
| 本番では `locals.user` が常に `null` になる | Cookie の名前かドメインが環境によって違うか、ページがまだプリレンダーされています。ミドルウェアで `context.cookies.has("session")` をログに出してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### Astro の action の中でログイン中のユーザーを取得するには？

src/middleware.ts で context.locals にユーザーを設定し、ハンドラーの第 2 引数から context.locals.user を読みます。もう 1 つの方法は Astro のセッションで、context.session を使います。

### Astro の action は JavaScript なしでも動きますか？

はい、オンデマンドでレンダリングするページなら動きます。フォームの action をその action に、method を POST に設定し、ページで Astro.getActionResult() を使って結果を読みます。

### Astro でフィードバックがどの画面から来たかを知るには？

Astro.routePattern を hidden の入力欄に描画します。/app/projects/[id]/settings のようなルートファイルのパターンが入るので、回答を ID ではなく画面ごとにまとめられます。

### Ikiform は無料ですか？

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

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [SvelteKit のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/sveltekit.md): SvelteKit のアプリ内フィードバックフォーム。レイアウトのフォームが 1 つのフォームアクションに送信し、アクションがサーバー側で locals のユーザーとリクエストのページを追加します。
- [Nuxt のフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form/nuxt.md): Nuxt のアプリ内フィードバックフォーム。ユーザーのセッションを読み、Referer からページを取得し、JavaScript の読み込み前でも動くサーバールートを使います。
- [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): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
