All docs
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:crypto works.
  • Cloudflare Workers: use @sveltejs/adapter-cloudflare. Swap the verification to crypto.subtle.importKey + sign since node:crypto isn't on the edge runtime.

What's next