Skip to content

SvelteKit contact form without a server route

Ship a SvelteKit contact form without a server route - pure form action to a hosted endpoint, validation, error handling, and progressive enhancement.

10 min read 1,811 words By Florian Wartner

SvelteKit gives you a server runtime out of the box. The natural instinct, when adding a contact form, is to reach for it - a +server.ts route, a POST handler, some validation, a fetch to your email provider. This is a perfectly reasonable approach. It is also more infrastructure than a contact form usually needs.

This is the small, boring approach instead: a SvelteKit form that posts directly to a hosted form endpoint, with no backend code at all. The form still feels native - progressive enhancement, client-side validation, error states, redirect-after-submit - but the SvelteKit project stays statically deployable, the bundle stays small, and nobody has to maintain a server route that does one thing once.

Do you actually need a SvelteKit server route for a contact form?

In most cases, no. The contact form needs to receive a POST, validate, store durably, notify a human, optionally autoresponder - every one of those belongs in a hosted form backend, not your application code.

The reasons the server route looks tempting and the counter-arguments:

"I want validation server-side." Hosted form backends validate. Required, email shape, length limits - all configurable on the form itself.

"I want to control what happens after submit." You still do. The endpoint responds with JSON or follows a redirect URL you configured.

"I want spam protection." Hosted backends include honeypots, rate limiting, adaptive challenge, AI moderation. The DIY route means building it yourself.

"I want my data in my database." The strongest argument. If the form feeds a custom CRM or product database, keep the server route. For most contact forms, the data lives in the form vendor's inbox or in a downstream CRM via webhook - your database is not in the path.

The decision: standalone "get in touch" surface → skip the server route. Part of a multi-step application flow → keep it. Most SvelteKit projects have the first kind.

What does the drop-in approach look like?

The shape of a SvelteKit contact form that posts to a hosted endpoint:

svelte
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  let submitted = $state(false);
  let error = $state<string | null>(null);

  async function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    error = null;
    const form = event.target as HTMLFormElement;
    const data = new FormData(form);

    const response = await fetch('https://formspring.io/f/abc123', {
      method: 'POST',
      body: data,
      headers: { Accept: 'application/json' },
    });

    if (response.ok) {
      submitted = true;
      form.reset();
    } else {
      error = 'Something went wrong. Please try again.';
    }
  }
</script>

{#if submitted}
  <p>Thanks - we'll be in touch.</p>
{:else}
  <form on:submit={handleSubmit}>
    <label>
      Email
      <input type="email" name="email" required />
    </label>
    <label>
      Message
      <textarea name="message" required minlength="10"></textarea>
    </label>
    <button type="submit">Send</button>
    {#if error}<p class="error">{error}</p>{/if}
  </form>
{/if}

The whole component is forty lines. No +server.ts, no +page.server.ts, no environment variables holding API keys, no email-provider SDK. The form posts directly to the hosted endpoint. The endpoint handles validation, storage, notification, autoresponder.

The headers detail matters. Accept: application/json tells the hosted endpoint to respond with JSON instead of doing a redirect. The form stays in place; SvelteKit handles the success state. Without that header most form backends will respond with a 302 redirect, which the browser will follow - fine if that's what you want, but most modern UX preserves the page state and shows an inline success message.

How do you get progressive enhancement with use:enhance?

The version above relies on JavaScript. Visitors with JS disabled, or with JS that fails to load before they submit, will not get the friendly success state.

SvelteKit's use:enhance action is designed exactly for this case - make the form work without JavaScript first, then progressively enhance with JavaScript on top. The pattern requires a SvelteKit form action (a +page.server.ts actions block), which seems to contradict the "no server route" goal. There's a workable middle ground.

Use a SvelteKit form action that does one thing: proxy the submission to the hosted endpoint and return the result. This is one short function, not a fully implemented form handler:

ts
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();

    const response = await fetch('https://formspring.io/f/abc123', {
      method: 'POST',
      body: data,
      headers: { Accept: 'application/json' },
    });

    if (!response.ok) {
      return fail(response.status, { error: 'Submission failed' });
    }

    return { success: true };
  },
};

The page component then uses use:enhance to progressively enhance:

svelte
<script lang="ts">
  import { enhance } from '$app/forms';
  export let form;
</script>

{#if form?.success}
  <p>Thanks - we'll be in touch.</p>
{:else}
  <form method="POST" use:enhance>
    <input type="email" name="email" required />
    <textarea name="message" required minlength="10"></textarea>
    <button type="submit">Send</button>
    {#if form?.error}<p class="error">{form.error}</p>{/if}
  </form>
{/if}

The result: visitors with JavaScript get an in-place form that submits without a page reload. Visitors without JavaScript get a working form that posts to the action, gets the success state via a full page navigation, and never knows JavaScript was supposed to be there.

This is the SvelteKit-native pattern. It uses a server route, but a trivial one - the route does not handle validation, storage, or notification. All of those still live in the hosted endpoint. The server route is a five-line proxy.

If you cannot use any server code at all (purely static deployment to a CDN with no functions), drop back to the fetch-only version above. Progressive enhancement is the loss, but the form still works.

What client-side validation is worth doing?

The HTML5 constraint validation built into the browser handles most cases for free:

  • type="email" enforces email shape.
  • required blocks empty submission.
  • minlength / maxlength enforce length bounds.
  • pattern enforces a regex for specific formats.

For a contact form, that is usually enough. The browser shows native error messages before the form ever submits, the hosted endpoint validates again on receipt, and any escaped invalid data bounces with a structured error.

Where browser validation falls short:

  • Custom messages. The default browser error text is generic. Override with the setCustomValidity API or a small Svelte action.
  • Cross-field validation. "Phone or email must be filled" needs JavaScript.
  • Async validation. "Does this email already exist in our system" requires a fetch and a debounce.

For most contact forms, none of these apply. The instinct to install a form library on top of a contact form is usually overkill - the browser does the work.

How do you handle errors without a +server.ts?

The fetch-only version above shows the pattern. The hosted endpoint responds with a structured error on validation failure - typically a 422 with a JSON body listing the failed fields (see RFC 9110 §15.5.21 for the canonical 422 semantics). Catch the non-OK response, surface the message.

A slightly more polished version:

svelte
<script lang="ts">
  let errors = $state<Record<string, string>>({});

  async function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    errors = {};
    const form = event.target as HTMLFormElement;
    const response = await fetch('https://formspring.io/f/abc123', {
      method: 'POST',
      body: new FormData(form),
      headers: { Accept: 'application/json' },
    });

    if (response.ok) {
      // success
      return;
    }

    const body = await response.json();
    if (body.errors) {
      errors = body.errors;
    } else {
      errors = { _: 'Submission failed. Please try again.' };
    }
  }
</script>

The per-field errors render inline next to the relevant input. The fallback error catches network failures and unexpected responses.

A reasonable default: show a single combined error message for the first attempt, then surface field-level errors on subsequent attempts. Visitors get a chance to read the form's own labels before being bombarded with red text.

When DO you need a +server.ts route?

The honest cases:

  • The form contains conditional logic that depends on application state (current user, cart contents, account tier). The decision of what to do with the submission depends on data only your application has.
  • The submission triggers an immediate action in your application database (creating an account, scheduling a meeting, generating a quote). The form is a thin layer over an internal operation.
  • You want to avoid a third-party dependency for compliance or operational reasons. Some industries require all data ingress to flow through controlled infrastructure.
  • The form is high-traffic enough that the per-submission cost of a hosted endpoint matters.

For everything else - marketing site contact forms, support request forms, newsletter signups, "request a demo" forms - the hosted endpoint plus a thin client component is the path of least friction.

What about deployment?

The fetch-only approach deploys to anything that serves static files. The use:enhance proxy version needs a runtime - Vercel serverless, Netlify edge functions, Cloudflare Pages workers all handle it on free tiers. The trap: shipping the proxy action without the right adapter. The build succeeds, the page loads, the action 500s. Check the adapter against the deployment target before production.

The other JAMstack frameworks

This pattern is not unique to SvelteKit. The same approach works in Astro, Next.js, and React. The framework choice does not change the architecture - there's a form, it posts to a hosted endpoint, the framework handles the UI around it. The differences are syntactic. The shape is the same. For a broader take on the pattern across frameworks, see the JAMstack contact form guide.

The reason the pattern keeps recurring is that it is genuinely the right answer for static sites. The contact form is the smallest, least application-specific surface on the site. Building backend infrastructure for it is gold-plating. Posting it to a hosted endpoint is the boring, correct choice - and the SvelteKit version of boring is forty lines of component code.

Related from this desk

From the field

Written by Florian Wartner

Florian Wartner

Founder of Formspring and Pixel & Process. Senior full-stack engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.

Give your next important form a real home.

Start free with one form. Add ownership, private files, and clear history before responses pile up in inboxes.

·· no card · 50 submissions / mo · no countdown