3 min read
SvelteKit recipe
SvelteKit's form actions are the cleanest pattern for this — submit progressively, validate server-side, return typed errors.
Form action
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
export let form;
</script>
<form method="POST" use:enhance>
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button>Send</button>
</form>
{#if form?.ok}
<p>Thanks — we'll get back to you.</p>
{:else if form?.error}
<p class="err">{form.error}</p>
{/if}
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import { FORMSPRING_URL } from '$env/static/private';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = String(data.get('email') ?? '');
const message = String(data.get('message') ?? '');
if (!email || !message) {
return fail(400, { error: 'Email and message are required.' });
}
const r = await fetch(FORMSPRING_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, message }),
});
if (!r.ok) {
return fail(502, { error: 'Submission failed. Try again.' });
}
return { ok: true };
},
};
use:enhance keeps the page interactive — no full reload, the response replaces form and your template re-renders. Without JS it falls back to a native form POST. Same code, both worlds.
Server endpoint pattern
When you don't want a +page.server.ts (e.g. submitting from a custom widget elsewhere), use a +server.ts:
// src/routes/api/contact/+server.ts
import { json } from '@sveltejs/kit';
import { FORMSPRING_URL } from '$env/static/private';
export async function POST({ request }) {
const body = await request.json();
const r = await fetch(FORMSPRING_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return json(await r.json(), { status: r.status });
}
Verify webhooks in hooks.server.ts
Centralize signature verification by checking the path in your global hook:
// src/hooks.server.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { WEBHOOK_SECRET } from '$env/static/private';
import { error } from '@sveltejs/kit';
export async function handle({ event, resolve }) {
if (event.url.pathname === '/api/formspring-webhook') {
const sig = event.request.headers.get('x-formspring-signature') ?? '';
const raw = await event.request.clone().text();
const expected = createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
throw error(401, 'invalid signature');
}
}
return resolve(event);
}
The route handler can then trust event.request and parse without re-checking.
Deploying
- Vercel / Netlify: pick the matching adapter,
node:cryptoworks. - Cloudflare Workers: use
@sveltejs/adapter-cloudflare. Swap the verification tocrypto.subtle.importKey+signsincenode:cryptoisn't on the edge runtime.