新しい SaaS のウェイトリスト3 分で読めます
SvelteKit でウェイトリストを作る
SvelteKit 3 のウェイトリスト。フックが最初の ?ref= を Cookie に保存し、名前付きのフォーム Action が登録を転送し、再訪者には登録済みと表示します。
訪問者の最初のリクエストで hooks.server.ts が ?ref= を読んで Cookie に保存するので、サイト内を回った後でも、連れてきたリンクの成果として登録を記録できます。名前付きのフォーム Action が Cookie を読み、シークレットキーで登録をフォームバックエンドに転送し、2 つ目の Cookie を設定するので、再訪した人には再び登録を求めず挨拶を表示できます。
メインガイドの SvelteKit のタブは、デフォルトの Action と use:enhance を使い、ref は homepage に固定しています。このページでは、実際のローンチに必要なものを足します。訪問者はニュースレターからブログ記事に来て、しばらく読み、2 ページ先で登録します。その登録は、メールアドレスを入力したページではなく、ニュースレターの成果として記録したいはずです。
SvelteKit ならではの違い
SvelteKit の 2 つの機能がこれにうまく合い、1 つのルールが構成を決めます:
| 機能 | ここでの役割 |
|---|---|
hooks.server.ts の handle | サーバーレンダリングするすべてのリクエストの前に実行されるので、訪問者がどのページに着いても ?ref= を見られる |
event.cookies | 最初の流入元を 30 日間保存する。Action が読み戻すので、改ざんされうる hidden の入力欄が要らない |
use:enhance 付きのフォーム Action | 1 つのサーバーコードで、JavaScript なしでも送信でき、読み込まれれば機能を上乗せする |
| ルール | Action のあるページはプリレンダーできない。その POST にサーバーが応答する必要があるため |
hidden の入力欄ではこれはできません。フォームのあるページしかわからず、URL のタグは最初のクリックで消えてしまいます。サーバーで設定した Cookie ならすべてのページで保持され、訪問者が見たり編集したりすることもありません。
これはファーストタッチのアトリビューションで、最初にタグ付きで来たリンクが勝ちます。後で別のリンクから来ても上書きしません。最後のリンクを評価したいなら、!event.cookies.get(...) のチェックを外してください。
- 訪問者が ?ref=news 付きのリンクからブログ記事に来ます。
- handle フックがタグを見つけ、waitlist_ref の Cookie がまだないので 30 日間の Cookie を設定します。
- 料金ページを読み、ホームページに戻ります。どちらの URL にもタグはありませんが、Cookie には残っています。
- 登録します。join の Action が waitlist_ref を読み、ref を news にして登録を Ikiform に転送します。
- 成功すると Action が waitlist_joined を設定するので、次の訪問ではホームページにフォームではなくお礼の一文が表示されます。
プロジェクトを準備する
SvelteKit のドキュメントガイドのとおりにフォームとシークレットキーを作成します。このページは SvelteKit 3 を使います。ファイル:
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envシークレットを宣言する
SvelteKit 3 では $env/... モジュールが非推奨になり、宣言した変数を使うようになりました。キーは src/env.ts で宣言します。変数は public と指定しない限り非公開で、$app/env/private はブラウザーのコードにインポートできません。
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。Action は email と ref を送ります。フォームの Fields タブでフィールドを定義している場合は、両方を含めてください:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}作ってみる
流入元を記録する
import type { Handle } from "@sveltejs/kit/hooks";
const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;
export const handle: Handle = async ({ event, resolve }) => {
const query = event.url.searchParams;
const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();
if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
}
return resolve(event);
};- タグは小文字にし、Ikiform の
max_lengthが許すのと同じパターンに一致しなければなりません。細工されたリンクでゴミが保存されることはありません。 httpOnlyで Cookie をページのスクリプトから遠ざけます。sameSite: "lax"でも、フォーム自身のPOSTには Cookie が送られます。- SvelteKit 3 では Cookie の
pathのデフォルトが/なので、指定しなくてもすべてのルートに届きます。
名前付きの Action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;
export const load: PageServerLoad = ({ cookies }) => ({
joined: cookies.get("waitlist_joined") === "yes",
});
export const actions = {
join: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });
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: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email,
ref: cookies.get("waitlist_ref") ?? "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(response.status === 429 ? 429 : 400, {
email,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
});
}
cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
return { joined: true };
},
} satisfies Actions;- 名前付きの Action
joinにしておけば、後でこのページに他の Action を足す余地が残ります。SvelteKit では、名前付きの Action とデフォルトの Action を並べられません。 - ブラウザーの
Originヘッダーを引き継いで渡します。Ikiform は無料のlocalhostテスト送信をこのヘッダーで判定しますが、Action 自身のfetchはこれを送らないからです。 fail()は入力されたメールアドレスとメッセージを返します。SvelteKit 3 では、enhance された送信はfail()に渡したステータスを使うので、page.statusは400や429になります。loadがwaitlist_joinedを読むので、再訪者には登録済みであることが表示されます。これは 1 つのブラウザー向けの気遣いで、重複排除ではありません。別のデバイスからの 2 回目の登録も Ikiform は保存します。
ページ
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
let pending = $state(false);
</script>
<h1>Acme is almost here</h1>
{#if data.joined || form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
action="?/join"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}action="?/join"で Action を指定します。JavaScript がなければブラウザーはそこに送信し、SvelteKit はformを設定してページを描画します。use:enhanceは、ページのハイドレーション後にfetchで送信します。update({ reset: false })はエラーの後も入力欄にメールアドレスを残すので、訪問者は打ち直さずにタイプミスだけを直せます。pendingで Action が応答するまでボタンを無効にし、遅いスマートフォンでの二重送信を防ぎます。data.joinedはloadから、form.joinedは Action から来ます。両方を確認すれば、1 つの条件で再訪者と新しい登録の両方に対応できます。
ハニーポットはそのまま Ikiform に届くので、埋めたボットはスパムとして保存されます。シークレットキー付きの送信は Ikiform の人間確認をスキップするので、ここで Spam check をオンにしても意味はありません。
プリレンダーとデプロイ
ホームページには Action があるので、サーバーでレンダリングする必要があります。ブログ記事などのコンテンツは export const prerender = true でプリレンダーできますが、訪問者がプリレンダーされたファイルを開いても handle は実行されません。実行されたのはビルド時の 1 回だけです。プリレンダーされたページへ直接来たタグ付きリンクでは Cookie は設定されません。キャンペーンのリンクは、ホームページなどサーバーレンダリングされるページに向けてください。
| ホスト | アダプター | 注意点 |
|---|---|---|
| Vercel | @sveltejs/adapter-vercel | SvelteKit 3 ではここで edge ランタイムがなくなったので、Action は Node の関数で動きます |
| Netlify | @sveltejs/adapter-netlify | キーは Environment variables で設定します |
| Cloudflare | @sveltejs/adapter-cloudflare | アダプターのピア依存である wrangler 4.118 以降が必要です。キーは wrangler secret put IKIFORM_SECRET_KEY で設定します |
動作を確認する
vite devを実行し、http://localhost:5173/?ref=testを開いてから、クエリを外して再読み込みします。ブラウザーの開発者ツールで見ると、waitlist_refの Cookie はtestのままです。- 登録します。再読み込みなしでフォームがお礼の一文に置き換わり、Submissions の登録の
refはtestになっています。 - ページを再読み込みします。フォームではなくお礼の一文が表示されます。
- Cookie を削除し、JavaScript をオフにして
a@bを送信します。エラー付きでページが戻り、アドレスは入力欄に残っています。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 vite devからの送信はlocalhostのオリジンを持つので、テスト送信として数えられます。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースはlocalhostからのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 から 500 件の Pro を選ぶか、買い切りで 10,000 件 $49 または 50,000 件 $149 のパックを購入してください。パックのクレジットに有効期限はありません。料金を参照してください。
ハマりどころ
| 問題 | 対処法 |
|---|---|
| Action のあるページはプリレンダーできないというエラーでビルドが失敗する | レイアウトがすべてのルートに prerender = true を設定しています。ウェイトリストのページの +page.server.ts で export const prerender = false を設定してください。 |
ref が常に direct になる | タグ付きのリンクがプリレンダーされたページを指していたため handle が見ていないか、以前の訪問ですでに Cookie が設定されています。開発者ツールで Cookie を確認してください。 |
アップグレード後に $env/static/private の警告が出る | SvelteKit 3 では $env モジュールが非推奨になりました。キーを src/env.ts で宣言し、$app/env/private からインポートしてください。 |
unknown_field 付きの 422 | フィールド定義に ref がありません。上の定義のように追加してください。 |
402 plan_required | ワークスペースにプランがなく、送信が本番のサイトから来ています。送信データは失われず、保留されています。Settings › Plan and usage でプランを選ぶかパックを購入すると、保留中の送信データが届きます。 |
よくある質問
最初のリクエストで handle フックがクエリを読み、タグを httpOnly の Cookie に保存し、フォームの Action で Cookie を読みます。流入元はページを移動しても残り、フォームに現れることはありません。
できません。Action のあるページは POST に応答するサーバーが必要なので、SvelteKit はプリレンダーを拒否します。他のページをプリレンダーし、フォームのページはサーバーレンダリングのままにしてください。
そのブラウザーでだけです。Ikiform はメールアドレスで重複排除しないので、リストを管理している場所かエクスポート時に重複を取り除いてください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com