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

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

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

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

Preet SutharIkiform 創業者

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 に置き換えます。

1 つの Astro フィードバックフォームが同じアクションに届く 2 つの経路JavaScript なしスクリプトありフォームのPOST現在のページへミドルウェアCookie からlocals.usersendFeedbackZod、次にfetchページを再描画getActionResult()actions.sendFeedback()フォームからFormDataミドルウェア同じCookie、同じユーザーsendFeedback同じハンドラーフォームを差し替え{ data } か {error }ページの URL結果fetchJSON1 つの Astro フィードバックフォームが同じアクションに届く 2 つの経路JavaScript なしスクリプトありフォームの POST現在のページへミドルウェアCookie から locals.usersendFeedbackZod、次に fetchページを再描画getActionResult()actions.sendFeedback()フォームから FormDataミドルウェア同じ Cookie、同じユーザーsendFeedback同じハンドラーフォームを差し替え{ data } か { error }ページの URL結果fetchJSON
  1. JavaScript がなければ、ブラウザーは URL にアクション名を付けて、フォームを現在のページに送信します。
  2. 毎回のリクエストで最初にミドルウェアが動き、サインイン中のユーザーを context.locals に入れます。
  3. アクションは評価と画面を検証し、ユーザーとページを加えて、シークレット付きで Ikiform に送信します。
  4. Astro がページをもう一度描画し、Astro.getActionResult() がお礼の一文かエラーを表示します。
  5. JavaScript があれば、スクリプトが同じアクションを fetch で呼びます。ミドルウェアとハンドラーはまったく同じように動きます。
  6. スクリプトはフォームをお礼の一文に差し替えるか、その下にエラーを書き込みます。再読み込みはありません。
1 つのハンドラーに 2 つの送り方。どちらのリクエストもページから来るので、どちらにもセッション Cookie と、Referer にページが入っています。

プロジェクトの準備

プロジェクトの構成
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
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 を読んでください。

src/middleware.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
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

Action

src/actions/index.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
---
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
---
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/cloudflarenpx 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 に合わせてください。

よくある質問

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