ウェブサイトやアプリのフィードバックフォーム3 分で読めます
Astro でアプリ内フィードバックフォームを作る
Astro のアプリ内フィードバックフォーム。ユーザーはミドルウェアの locals から、ページはリクエストから、画面はルートのパターンから取得するアクションを作ります。
accept: "form" を指定した Astro のアクションを書き、Zod で評価を検証し、context.locals からサインイン中のユーザーを読み、astro:env のシークレットを使って回答をフォームバックエンドに転送します。フォームの action にそのアクションを指定して描画すれば JavaScript なしでも送信でき、JavaScript があるときは同じアクションをその場で呼ぶ短いスクリプトを加えます。回答の対象ページはリクエストの Referer から取り、Astro.routePattern が画面の名前になります。
メインガイドの Astro タブでは、どの訪問者にもページがどれだけ役に立ったかを尋ねます。このページでは、サインインしたユーザーがいる Astro アプリの中にフィードバックを置きます。誰が書いたかはサーバーが決め、フォームが尋ねるのはスコアとコメントだけです。キーとフォームの設定については Astro のドキュメントガイドに詳しくあります。
誰が何を埋めるか
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 に置き換えます。
- JavaScript がなければ、ブラウザーは URL にアクション名を付けて、フォームを現在のページに送信します。
- 毎回のリクエストで最初にミドルウェアが動き、サインイン中のユーザーを context.locals に入れます。
- アクションは評価と画面を検証し、ユーザーとページを加えて、シークレット付きで Ikiform に送信します。
- Astro がページをもう一度描画し、Astro.getActionResult() がお礼の一文かエラーを表示します。
- JavaScript があれば、スクリプトが同じアクションを fetch で呼びます。ミドルウェアとハンドラーはまったく同じように動きます。
- スクリプトはフォームをお礼の一文に差し替えるか、その下にエラーを書き込みます。再読み込みはありません。
プロジェクトの準備
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 を示します。
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 タブにこの定義を貼り付けます。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
作ってみる
ユーザーを locals に入れる
ミドルウェアはすべてのページとアクションの前に動きます。そこで認証のコードが提供する方法でセッションを 1 回読めば、すべてのハンドラーがその結果を受け取れます。readSession はそのコードの代わりです。Astro 自身のセッションも使えます。その場合はアクションで context.session を読んでください。
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();
});declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}Action
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を引き継ぎます。詳しくは後述します。
フォームコンポーネント
---
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で結果を保存してリダイレクトできます。
ページ
---
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 のレート制限を選び、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 の行がブラウザーのものを転送するのでローカルのテストは無料になり、本番ではヘッダーが自分のドメインになります。
動作を確認する
astro devを実行してサインインし、プロジェクトの設定ページを開いて、コメント付きで 2 を送信します。- Submissions で、
screenが/app/projects/[id]/settingsであること、pageに実際の ID があること、user_idとemailが自分のものであることを確認します。 - JavaScript をオフにして、もう一度送信します。ページが再読み込みされ、お礼の一文が表示されます。
- セッション Cookie を削除して送信します。フォームにサインインを求めるメッセージが表示されます。
- 通知メールに返信します。宛先はサインイン中のユーザーです。
ハマりどころ
| 問題 | 対処法 |
|---|---|
| フォームは送信されるが、ページに結果が表示されない | ページがプリレンダーされています。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 に合わせてください。 |
よくある質問
src/middleware.ts で context.locals にユーザーを設定し、ハンドラーの第 2 引数から context.locals.user を読みます。もう 1 つの方法は Astro のセッションで、context.session を使います。
はい、オンデマンドでレンダリングするページなら動きます。フォームの action をその action に、method を POST に設定し、ページで Astro.getActionResult() を使って結果を読みます。
Astro.routePattern を hidden の入力欄に描画します。/app/projects/[id]/settings のようなルートファイルのパターンが入るので、回答を ID ではなく画面ごとにまとめられます。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com