【② フォーム作成】SvelteKit on Cloudflareでお問い合わせフォームをつくる
私たちは都内を中心に活動しているアマチュアオーケストラの Orchestra Canvas Tokyo です。
弊団のホームページ、ブログのリファクタリングにおいてできた、お問い合わせフォーム実装に関する知見をまとめた本シリーズ。
今回は SvelteKit でフォームの概形を作成していきます!
このシリーズの記事一覧
- ① サイト作成:SvelteKit x Cloudflare Pages
- ② フォーム作成:SvelteKit x Zod x Google reCAPTCHA v3 ← 今回の記事
- ③ セッション:SvelteKit x Cloudflare KV
- ④ データベース:SvelteKit x Cloudflare D1
- ⑤ メール送信:SvelteKit x Resend
このシリーズの完成物
はじめに
今回の記事は次のような構成で進めていきます。
- 要件をまとめ、使用技術を選定
- 送受信機能だけをもつフォームの作成
- Zod によるバリデーション機能を追加
- Google reCAPTCHA v3 による CAPTCHA 機能を追加
- フォームの UX 改善
内容はボリューミーですが、Cloudflare サービスとの連携を要さない部分を仕上げてしまいます!
要件
今回のフォームを構成する要素は次の通りです。
名称 | 種類 | 必須 | 備考 |
---|---|---|---|
氏名 | テキスト | NO | ≦99 文字 |
メールアドレス | テキスト | YES | ≦254 文字 |
カテゴリー | ドロップダウン | YES | |
本文 | テキストエリア | YES | ≦999 文字 |
技術選定
ライブラリ
フォーム作成のゴールドスタンダードは Felte などのフォームライブラリと Zod などのバリデーションライブラリを使用することです。
しかし、今回はフォームの規模が大きくなく、いずれの要素もシンプルなものです。
そのため、バリデーションライブラリとして Zod を使用するのみとしました。
CAPTCHA
bot による自動送信を防ぐため、CAPTCHA を用意するのも王道です。
せっかく Cloudflare を利用しているので Cloudflare Turnstile の使用を予定していました。
しかし、相性の問題かクライアントが頻繁にクラッシュしてしまい……
今回は定番の Google reCAPTCHA v3 を使用することとしました。
Cloudflare Turnstile は Google reCAPTCHA v3 にはないダークモードのスキンも用意されており、魅力的なんですよね……
いつかリベンジしたい!
クライアントサイドのフォーム実装
HTML マークアップ
Svelte のドキュメント通りに実装していきます。
参考:Form actions • Docs • Svelte
<form method="POST">
<label>
お名前
<input name="name" type="text" maxLength="99">
</label>
<label>
メールアドレス
<input name="email" type="email" maxLength="254" required>
</label>
<label>
カテゴリー
<select name="category" required>
<option value="" hidden selected></option>
<option value="concert">演奏会について</option>
<option value="others">その他</option>
</select>
</label>
<label>
本文
<textarea name="body" maxLength="999"></textarea>
</label>
<button type="submit">送信</button>
</form>
これで npm run dev
から開発環境を起動し、指定された URL から /contact
にアクセスすると、生 HTML の温かみに包まれたフォームが生まれています。
Form Action を作成
SvelteKit のフォーム操作、form action で送信内容を受け取ってみます。
先に作成したような、素直なフォームなら簡単に受け取ることができます。
import type { Actions } from './$types';
/**
* FormDataからObjectに変換する
* @param d 変換元のFormData
* @returns 変換後のObject
*/
function convertToObject(d: FormData): Record<string, FormDataEntryValue> {
const result: Record<string, FormDataEntryValue> = {};
d.forEach((value, key) => {
result[key] = value;
});
return result;
}
export const actions = {
default: async ({ request }) => {
const rawRequestBody = convertToObject(await request.formData());
console.log({ rawRequestBody });
return { success: true };
}
} satisfies Actions;
取り回しがいいように、FormData
型から Object
型への変換を挟んでいます。
そのため行数はかかりましたが、本質的にはとても簡単にリクエストを受け取ることができています!
ActionData を受け取る
サーバーサイドの Actions
からの戻り値を、クライアントサイドでは ActionData
として受け取ることができます。
試しに、console.log()
で受け取ったデータを確認してみます。
+ <script lang="ts">
+ import type { PageData, ActionData } from './$types';
+
+ let { data, form }: { data: PageData, form: ActionData } = $props();
+ console.log({ form })
+ </script>
+
<form method="POST">
Zod でバリデーション
type="email"
、maxLength
属性などを指定しているため、基本的にはクライアントサイドでバリデーションがかかっています。
しかし、安全性を考慮すると、サーバーサイドで再度のバリデーションは必要です。
サーバー サイド で 再度 、行います。
Zod を使うと、 schema を作成するだけで型やバリデート関数を用意してもらうことでできます!
早速インストールします。
npm install zod
/src/lib
配下に schema を置くのも手ですが、この schema はお問い合わせフォームでしか使いません。
ここではローカルに配置することとし、 contact
配下に作成します。
import { z } from 'zod';
export const requestBodySchema = z.object({
name: z.string().max(99),
email: z.string().email().max(254),
category: z.enum(['concert', 'others']),
body: z.string().max(999)
});
export type RequestBody = z.infer<typeof requestBodySchema>;
actions のコードでバリデーションをかけるようにします。
export const actions = {
default: async ({ request }) => {
const rawRequestBody = convertToObject(await request.formData());
- console.log({ rawRequestBody });
+ // バリデーションをかける
+ const validationResult = requestBodySchema.safeParse(rawRequestBody);
+ if (!validationResult.success) {
+ return { success: false, message: 'Invalid request body' };
+ }
+ const requestBody = validationResult.data;
+ console.log({ requestBody });
+
return { success: true };
}
} satisfies Actions;
きちんとバリデーションが通っていることを確認できます!
共通パラメータを整理する
現在の実装では、マジックナンバーが各所に散らばってしまっており、保守性があまりよくありません。
例えば、氏名の最大長が +page.svelte
と schema.ts
にハードコーディングされています。
ここでは、schema.ts
にまとめる方針とします。
+ export const NAME_MAX_LENGTH = 99;
+ export const EMAIL_MAX_LENGTH = 254;
+ export const BODY_MAX_LENGTH = 999;
+ const categoryKeys = ['concert', 'others'] as const;
+ export const CATEGORY_OPTIONS: Record<(typeof categoryKeys)[number], string> = {
+ concert: '演奏会について',
+ others: 'その他'
+ } as const;
export const requestBodySchema = z.object({
- name: z.string().max(99),
- email: z.string().email().max(254),
- category: z.enum(['concert', 'others']),
- body: z.string().max(999)
+ name: z.string().max(NAME_MAX_LENGTH),
+ email: z.string().email().max(EMAIL_MAX_LENGTH),
+ category: z.enum(categoryKeys),
+ body: z.string().max(BODY_MAX_LENGTH)
});
- import type { Actions } from './$types';
+ import type { Actions, PageServerLoad } from './$types';
+ import {
+ NAME_MAX_LENGTH,
+ EMAIL_MAX_LENGTH,
+ CATEGORY_OPTIONS,
+ BODY_MAX_LENGTH,
+ requestBodySchema
+ } from './schema';
+
+ export const load: PageServerLoad = async () => {
+ return {
+ NAME_MAX_LENGTH,
+ EMAIL_MAX_LENGTH,
+ CATEGORY_OPTIONS,
+ BODY_MAX_LENGTH
+ };
+ };
/**
* FormDataからObjectに変換する
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData, form: ActionData } = $props();
console.log({form})
</script>
<form method="POST">
<label>
お名前
- <input name="name" type="text" maxLength="99">
+ <input name="name" type="text" maxLength="{data.NAME_MAX_LENGTH}">
</label>
<label>
メールアドレス
- <input name="email" type="email" maxLength="254" required>
+ <input name="email" type="email" maxLength="{data.EMAIL_MAX_LENGTH}" required>
</label>
<label>
カテゴリー
<select name="category" required>
<option value="" hidden selected></option>
- <option value="concert">演奏会について</option>
- <option value="others">その他</option>
+ {#each Object.entries(data.CATEGORY_OPTIONS) as [key, description]}
+ <option value={key}>{description}</option>
+ {/each}
</select>
</label>
<label>
本文
- <textarea name="body" maxLength="999"></textarea>
+ <textarea name="body" maxLength="{data.BODY_MAX_LENGTH}"></textarea>
</label>
<button type="submit">送信</button>
</form>
風通しがよくなりましたね!
reCAPTCHA v3 を導入
reCAPTCHA Admin Console にて site key と secret key を発行します。
この際、ドメインを allow list 形式で指定します。
登録したドメインのサブドメインも自動で許容されます。
検証環境として、localhost
、127.0.0.1
も指定しておくと便利です。
クライアントサイド
まず、TypeScript 向け型定義をインストールしておきます。
npm install @types/grecaptcha
後は、公式のガイドに従い導入します。
参考:reCAPTCHA v3 | Google for Developers
ここでは、submit 時に動的にトークンを取得するように実装します。
<script lang="ts">
import type { ActionData, PageServerData } from "./$types";
+ import { applyAction, deserialize } from "$app/forms";
+ import type { ActionResult } from "@sveltejs/kit";
+ import { invalidateAll } from "$app/navigation";
let { data, form }: { data: PageData, form: ActionData } = $props();
console.log({form})
+
+ const RECAPTCHA_SITE_KEY = '6LcrCHcqAAAAAGwoYDnJR4xmIUNSfzCdgYZowBpX';
+ async function handleSubmit(event: { currentTarget: EventTarget & HTMLFormElement}) {
+ const data = new FormData(event.currentTarget);
+
+ // reCAPTCHAトークンを発行
+ // eslint-disable-next-line no-undef
+ const reCaptchaToken = await grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'submit' });
+ data.append('reCaptchaToken', reCaptchaToken);
+
+ // サーバーサイドに送信
+ const response = await fetch('/contact', {
+ method: 'POST',
+ body: data
+ });
+ const result: ActionResult = deserialize(await response.text());
+
+ // リクエストが成功した場合の一連のおまじない
+ if (result.type === 'success') {
+ // rerun all `load` functions, following the successful update
+ await invalidateAll();
+ }
+ applyAction(result);
+ }
</script>
+
+ <svelte:head>
+ <script src="https://www.google.com/recaptcha/api.js?render={RECAPTCHA_SITE_KEY}" async></script>
+ </svelte:head>
schema にも反映させておきます。
export const requestBodySchema = z.object({
name: z.string().max(NAME_MAX_LENGTH),
email: z.string().email().max(EMAIL_MAX_LENGTH),
category: z.enum(categoryKeys),
body: z.string().max(BODY_MAX_LENGTH),
+ reCaptchaToken: z.string()
});
バッジを隠す
reCAPTCHA v3 の画面表示は右下のバッジのみです。
しかし、残念なことに右下のバッジはダークモードに対応していません。
今回作成したホームページは黒基調のデザインであるため、バッジを隠す方針としました。
参考:よくある質問 | reCAPTCHA | Google Developers
<form method="POST" on:submit|preventDefault={handleSubmit}>
<!-- 省略 -->
+ <p class="recaptcha-description">
+ このサイトはreCAPTCHAによって保護されており、Googleの
+ <a href="https://policies.google.com/privacy">プライバシーポリシー</a>
+ と
+ <a href="https://policies.google.com/terms">利用規約</a>
+ が適用されます。
+ </p>
</form>
+
+ <style>
+ :global(.grecaptcha-badge) {
+ visibility: hidden;
+ }
+ </style>
サーバーサイド
Google の API を叩き、トークンを検証します。
環境変数の設定
secret たる secret key の管理が必要です。
環境変数ファイルを作成します。
RECAPTCHA_SECRET_KEY=xxxxxxxxxxxxxxxxxx
間違えて commit、ましてや push することがないよう、.gitignore
に指定されていることを念入りに確認しておきます。
忘れる前に、Cloudflare Pages の管理画面にも登録しておきます。
コーディング
SvelteKit における環境変数の取り扱いにならって実装していきます。
見通しをよくするため、別ファイルに切り出します。
import { RECAPTCHA_SECRET_KEY } from '$env/static/private';
export async function verifyCaptcha(token: string): Promise<boolean> {
const body = new FormData();
body.append('secret', RECAPTCHA_SECRET_KEY);
body.append('response', token);
const response = await (
await fetch('https://www.google.com/recaptcha/api/siteverify', {
body: body,
method: 'POST'
})
).json();
if (response?.success) return true;
return false;
}
/* 省略 */
+
+ // reCAPTCHAを検証
+ const captchaResult = verifyCaptcha(requestBody.reCaptchaToken);
+ if (!captchaResult) {
+ return { success: false, message: 'Invalid CAPTCHA token' };
+ }
/* 省略 */
簡単ですね!
UX の仕上げ
フォームの使い勝手を良くするため、次の処理を追加します。
- フォーム送信中はフォームの各要素を
disabled
にする - フォーム送信成功後はフォームの各要素を初期化する
このあたりは個人の経験や趣味嗜好が大いに反映される部分かと思います。
disabled
送信中は フラグ isSubmitting
を Svelte5 の runes に乗せます。
<script lang="ts">
/* 省略 */
const RECAPTCHA_SITE_KEY = '6LcrCHcqAAAAAGwoYDnJR4xmIUNSfzCdgYZowBpX';
+ let isSubmitting = $state(false);
async function handleSubmit(event: { currentTarget: EventTarget & HTMLFormElement }) {
+ isSubmitting = true;
/* 省略 */
applyAction(result);
+ isSubmitting = false;
}
</script>
<form method="POST">
<label>
お名前
- <input name="name" type="text" maxLength="{data.NAME_MAX_LENGTH}">
+ <input name="name" type="text" maxLength="{data.NAME_MAX_LENGTH}" disabled={isSubmitting}>
</label>
<label>
メールアドレス
- <input name="email" type="email" maxLength="{data.EMAIL_MAX_LENGTH}" required>
+ <input name="email" type="email" maxLength="{data.EMAIL_MAX_LENGTH}" required disabled={isSubmitting}>
</label>
<label>
カテゴリー
- <select name="category" required>
+ <select name="category" required disabled={isSubmitting}>
<option value="" hidden selected></option>
{#each Object.entries(data.CATEGORY_OPTIONS) as [key, description]}
<option value={key}>{description}</option>
{/each}
</select>
</label>
<label>
本文
- <textarea name="body" maxLength="{data.BODY_MAX_LENGTH}"></textarea>
+ <textarea name="body" maxLength="{data.BODY_MAX_LENGTH}" disabled={isSubmitting}></textarea>
</label>
- <button type="submit">送信</button>
+ <button type="submit" disabled={isSubmitting}>送信</button>
</form>
フォーム送信後に初期化
前述した変数 form
の更新を察知し、処理が成功した場合に初期化処理を走らせます。
/* 省略 */
$effect(() => {
- console.log({ form });
+ if (form?.success) {
+ // フォームを初期化する
+ (document.querySelector('[name=name]') as HTMLInputElement).value = '';
+ (document.querySelector('[name=email]') as HTMLInputElement).value = '';
+ (document.querySelector('[name=category]') as HTMLSelectElement).selectedIndex = 0;
+ (document.querySelector('[name=body]') as HTMLTextAreaElement).value = '';
+ }
});
おわりに
長丁場、おつかれさまでした!
実装した要素の数こそ多いものの、一つひとつがフォームを輝かせる一要素になるのは魅力的ですよね。
SvelteKit に閉じた話はここまでとし、次回からはいよいよ Cloudflare の深みに潜っていきます!
- ① サイト作成:SvelteKit x Cloudflare Pages
- ② フォーム作成:SvelteKit x Zod x Google reCAPTCHA v3
- ③ セッション管理:SvelteKit x Cloudflare KV ← 次の記事
- ④ データベース管理:SvelteKit x Cloudflare D1
- ⑤ メール送信:SvelteKit x Resend
Discussion