SvelteKit
Add a contact form to a SvelteKit site that works with adapter-static: plain form posts or progressive enhancement with fetch.
SvelteKit's own answer to forms is form actions, which run on the server. A site built with adapter-static has no server, so actions are off the table. The form can post to SilentForms instead: as plain HTML it works with JavaScript disabled, and a small onsubmit handler upgrades it to an in-page submission when JavaScript is available.
What you'll build#
A contact page in a prerendered SvelteKit site that:
- is a normal HTML form posting to the SilentForms endpoint, with a redirect to your own thank-you page,
- is progressively enhanced with
fetch, so visitors with JavaScript stay on the page, - reads the access key and site URL from
$env/static/public.
Why not form actions#
Form actions live in +page.server.js and need a running server to receive the POST. The SvelteKit docs are explicit that pages with actions cannot be prerendered, so adapter-static fails the build if you add one. use:enhance from $app/forms is built for those actions too: it expects a SvelteKit action response, not the JSON SilentForms returns. Posting to SilentForms directly, with your own handler for the enhancement, avoids both problems and keeps the site fully static.
If your SvelteKit app does run on a server (adapter-node, Vercel, Cloudflare), the approach below still works and saves you writing an action and wiring an email provider.
1. Configure the static adapter#
If you have not already, install the adapter and prerender every page:
npm install -D @sveltejs/adapter-static// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: { adapter: adapter() },
};// src/routes/+layout.js
export const prerender = true;2. Add public environment variables#
Get a key on the access key page and add it to .env, along with the URL your site is served from:
PUBLIC_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HERE
PUBLIC_SITE_URL=https://example.comSvelteKit only allows variables prefixed with PUBLIC_ to be imported into browser code, from $env/static/public. "Static" means they are replaced at build time, which is what a prerendered site needs. The key is safe to publish: it only routes submissions to your inbox.
PUBLIC_SITE_URL is there because the redirect field needs an absolute URL. During prerendering, page.url.origin is a placeholder, not your real domain, so do not build the redirect from it.
3. The contact page#
Create src/routes/contact/+page.svelte:
<script>
import { PUBLIC_SILENTFORMS_KEY, PUBLIC_SITE_URL } from '$env/static/public';
let status = $state('idle'); // idle | sending | sent | error
let feedback = $state('');
async function enhance(event) {
event.preventDefault();
const form = event.currentTarget;
status = 'sending';
feedback = '';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
form.reset();
status = 'sent';
feedback = 'Thanks! Your message has been sent.';
} catch (error) {
status = 'error';
feedback = error.message || 'Something went wrong. Please try again.';
}
}
</script>
<svelte:head>
<title>Contact</title>
</svelte:head>
<h1>Contact us</h1>
<form action="https://silentforms.com/api/submit" method="POST" onsubmit={enhance}>
<input type="hidden" name="accessKey" value={PUBLIC_SILENTFORMS_KEY} />
<input type="hidden" name="redirect" value="{PUBLIC_SITE_URL}/contact/thanks" />
<label for="cf-name">Name</label>
<input id="cf-name" name="name" type="text" autocomplete="name" required />
<label for="cf-email">Email</label>
<input id="cf-email" name="email" type="email" autocomplete="email" required />
<label for="cf-message">Message</label>
<textarea id="cf-message" name="message" rows="5" required></textarea>
<input type="checkbox" name="botcheck" style="display: none" tabindex="-1" autocomplete="off" />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send message'}
</button>
<p role={status === 'error' ? 'alert' : 'status'}>{feedback}</p>
</form>And the thank-you page for visitors without JavaScript, src/routes/contact/thanks/+page.svelte:
<svelte:head>
<title>Message sent</title>
</svelte:head>
<h1>Thanks!</h1>
<p>Your message has been sent. <a href="/">Back to the home page</a>.</p>How the enhancement works#
Without JavaScript, the prerendered HTML is a complete form: the browser posts it to SilentForms, which redirects to /contact/thanks. With JavaScript, the onsubmit handler cancels that navigation and posts the same FormData with Accept: application/json, so SilentForms replies with JSON and the redirect field is ignored. One form, two paths, no duplicated field lists.
The handler reads form.action, so the endpoint URL is written once. The hidden accessKey input is in the FormData as well, so the key does not need to be added in JavaScript.
SvelteKit gotchas#
- Use
$env/static/public, not$env/dynamic/public, on a static site. Dynamic variables are read at request time, and a prerendered site has no requests to read them in. - Trailing slashes. If your host serves
/contact/thanks/but not/contact/thanks, setexport const trailingSlash = 'always'in the layout and include the slash in the redirect URL, or the thank-you page returns 404. - Unlinked pages are not prerendered. The prerenderer finds pages by following links from
/. Nothing links to/contact/thanks, so it is skipped and returns 404 on a static host. Add it to the entries insvelte.config.js:kit: { adapter: adapter(), prerender: { entries: ['*', '/contact/thanks'] } }.
Testing#
- Run
npm run dev, open/contactand submit. The message appears under the button and the email arrives. - Disable JavaScript in dev tools and submit again. You should land on
/contact/thanks. - Run
npm run buildandnpm run previewto test the prerendered output. A build error mentioning actions means a+page.server.jswithactionsis still in the route.
Next steps#
- The component on its own, without SvelteKit: Svelte.
- More on the redirect field: Redirect after submit.
- Deploying to Cloudflare? See Cloudflare Pages.