🤞🏽 Making your SvelteKit Form Work
We start with some basic SvelteKit Form example snippets, in this post. SvelteKit form issues are one of the topics I get asked for help on most often. So, with the basic example out of the way, we look at 10 pitfalls you should try to avoid in your SvelteKit forms. This should help you debug any issues. If you are still having issues not covered here though, the best piece of advice I can offer is to cut out features until you have things working. Then, once you have the issues fixed, add the features back in one-by‑one.
Let’s start with that base example. We will later add feedback for missing fields, server-side validation using Zod and user experience enhancements.
🧱 Minimal SvelteKit Form Example
This minimal code is missing some common user experience and feedback features, but should be enough to get us going!
<script lang="ts">
</script>
<main>
<h1>Contact form</h1>
<form method="POST">
<p>Leave a message</p>
<label for="email">Email</label>
<input
id="email"
type="email"
required
name="email"
autocomplete="username"
placeholder="trinidad@example.com"
/>
<label for="message">Message</label>
<textarea id="message" required name="message" placeholder="Leave your message…" rows={4} />
<button type="submit">Send</button>
</form>
</main>
+page.svelte
: what we Have so Far
- basic form using web standards
- input fields have corresponding label elements, with
for
value matching the inputid
- there is no
preventDefault
, because we are relying on browser primitives to submit the form
Corresponding Server Code
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const email = form.get('email');
const message = form.get('message');
if (typeof email === 'string' && typeof message === 'string') {
// Remember to obfuscate any real, personally identifiable information (PII) from logs,
// which unauthorised personnel might have access to.
console.log({ email, message });
// Add logic here to process the contact form.
}
throw redirect(303, '/');
}
};
+page.server.ts
: what we Have so Far
- The handler is named
default
in line5
, because there is noaction
attribute on theform
element in the Svelte markup. We see how to use named handlers later. -
email
&message
inform.get
method calls (lines7
&8
) match thename
attribute value on theinput
andtextarea
elements in the Svelte markup. - Working in TypeScript, form elements are of type
FormDataEntryValue
, and we should narrow the type to a more usefulstring
, ahead of processing (line10
).
+page.server.ts
: what is Missing
- We just
console.log()
the input email and message values. A typical workflow would involve emailing the message details to a team member who is authorized to handle personally identifiable information (PII). See example code for sending this email using the Postmark REST API below. - There is no check that the email is valid nor that the visitor left a message. We see how to use Zod for validation further down.
- There is no feedback. We redirect the user to the home page, even if the data was not as expected. We will return a SvelteKit
fail
object, below, to provide feedback in the markup.
Postmark Example: Emailing Form Details to a Team Member
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { POSTMARK_SERVER_TOKEN } from '$env/static/private';
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const email = form.get('email');
const message = form.get('message');
if (typeof email === 'string' && typeof message === 'string') {
await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Postmark-Server-Token': POSTMARK_SERVER_TOKEN
},
body: JSON.stringify({
From: 'admin@example.com',
To: email,
Subject: 'Contact form message',
TextBody: JSON.stringify({
email,
message
}, null, 2),
MessageStream: 'outbound'
})
});
}
throw redirect(303, '/');
}
};
See Postmark docs for more on the REST API.
⚠️ SvelteKit Forms: 10 Mistakes to Avoid
Next, we can add some missing features, pointing out trip hazards along the way.
⛔️ No SSG allowed! In your
svelte.config.js
file, use@sveltejs/adapter-auto
(the default) or another SSR compatible adapter, such as@sveltejs/adapter-cloudflare
or@sveltejs/adapter-netlify
. Also, make sure you do not have prerender directives in your server code for form handling routes.
You can build a completely static site with forms in SvelteKit, though you will probably want to use edge functions or serverless functions to handle form submissions. The techniques in this post will not work with prerendered pages.📮 Most of the time you will want the
POST
form method. As best practice, when there is a side effect (e.g. you log a user in, add a row to the database etc.), use thePOST
method on the form element. UseGET
when the user just needs to request a document. See MDN HTTP request method docs-
🫱🏾🫲🏼 Add an
action
attribute to the form element for named handlers. Above, we used thedefault
handler in the server action code. You might want to name your handlers, and if you do, you must include anaction
attribute on the form element in the markup. Use this approach to make code intention clearer, or when you have more than one form on a single route.
src/routes/+page.svelte
:
<form action="?/contact" method="POST"> <!-- TRUNCATED --> </form>
src/routes/+page.server.ts
:
export const actions: Actions = { contact: async ({ request }) => { const form = await request.formData(); /* TRUNCATED... */ } }
🫣 Don’t trust users! By all means, use client-side validation, to enhance user experience, but do not rely on it. A savvy user might manipulate the form response, wreaking havoc in your server logic! The Zod library is a common choice for validation, and we see how to integrate it further down. Zod saves you having to work out your own Regex to validate email addresses and such like.
-
💯 Send the user helpful feedback when their input is invalid. This goes hand-in-hand with validation, from the previous tip. SvelteKit server actions can return an
ActionFailure
using thefail
helper method. This lets you send error messages to the client, as well as track valid inputs (saving the user having to re-enter them).
import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; export const actions: Actions = { contact: async ({ request }) => { const form = await request.formData(); const email = form.get('email'); const message = form.get('message'); if (typeof email !== 'string') { return fail(400, { message: typeof message === 'string' ? message : '', error: { field: 'email', message: 'Check your email address.' } }); } if (typeof message !== 'string') { return fail(400, { email, error: { field: 'message', message: 'Don’t forget to leave a message!' } }); } /* TRUNCATED... */ } }
Improving on the initial version, here, if the
email
is not a string (we introduce further checks when we look at Zod below), we can: (i) send back the existing input for the message field, (ii) return the customerror
object which shows which field is awry and what the issue is. -
🤗 Make sure feedback is accessible. We can make use of the new feedback from the previous tip to let the user know when the inputs were not as expected. You can use
aria-invalid
andaria-describedby
attributes to keep this feedback accessible.
<script lang="ts"> import type { ActionData } from './$types'; export let form: ActionData; let { email, error, message } = form ?? {}; $: ({ email, error, message } = form ?? { email: '', error: { field: '', message: '' }, message: '' }); </script> <main> <h1>Contact form</h1> <form action="?/contact" method="POST"> <p>Leave a message</p> <label for="email">Email</label> <input id="email" type="email" required name="email" autocomplete="username" placeholder="trinidad@example.com" aria-invalid={error?.field === 'email'} aria-describedby={error?.field === 'email' ? 'email-error' : undefined} /> {#if error?.field === 'email'} <small id="email-error">{error?.message}</small> {/if} <!-- TRUNCATED... --> </form> </main>
SvelteKit plumbing makes the
email
,error
andmessage
variables returned from the action available on the frontend, viaexport let form
. See the Grammar check post for an explanation of why there is a double assignment for these identifiers.
Any email field error will be displayed by the template code in lines29
–31
. Notice, we will display the exact message set on the server, saving us re-creating a message. Theid
on thesmall
element in line30
is important to link the erroneous field, using anaria-describedby
attribute in line27
. Code for the message input is similar. -
🙏🏽 Avoid making the user re-enter details which were perfectly valid. An exception here is a password. Typically, you will ask the user to re-enter a password when a form input was rejected. For other fields, though, avoid winding up users; use a
value
attribute to repopulate the fields when a rejected form reloads:
<script lang="ts"> import type { ActionData } from './$types'; export let form: ActionData; let { email, error, message } = form ?? {}; $: ({ email, error, message } = form ?? { email: '', error: { field: '', message: '' }, message: '' }); </script> <main> <h1>Contact form</h1> <form action="?/contact" method="POST"> <p>Leave a message</p> <label for="email">Email</label> <input value={email} id="email" type="email" required name="email" autocomplete="username" placeholder="trinidad@example.com" aria-invalid={error?.field === 'email'} aria-describedby={error?.field === 'email' ? 'email-error' : undefined} /> <!-- TRUNCATED... --> </form> </main>
-
🏋🏽 Let Zod do the heavy lifting. Writing and testing validation code can eat up a lot of time. Use a validation library like Zod to save your back! There is minimal extra code for this example. Add Zod as a dev dependency (
pnpm add -D zod
) then, define a schema for the form data:
import { z } from 'zod'; export const contactFormSchema = z.object({ email: z .string({ required_error: 'Don’t forget to enter your email address!' }) .email('Check your email address.'), message: z .string({ required_error: 'Don’t forget to leave a message!' }) .min(1, 'Don’t forget to leave a message!') .max(1024, 'That’s a long message, try getting to the point quicker!') });
Here, we specify two fields:
email
andmessage
. By default, with Zod, all fields are required, and you can add.optional()
if the field is accepted, but not necessary.z.string()
details that we expectemail
to be a string, andrequired_error
is the error message we want to emit, when theemail
field is missing..email()
(line6
) is all we need to ask Zod to check the field value is a valid email. The string passed to.email()
is the error message to emit when the value is not a valid email. Similarly, formessage
, we setmin
andmax
lengths.
As a final step, we want to validate the form entries, on the server, using this new schema:- we validate the form entries in line
12
- note, we create a form entries plain JavaScript object in line
9
- if the validation fails, Zod throws an error, which we catch in the block starting at line
19
, and then return entered values and error messages, using the same format as earlier
import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; import { ZodError } from 'zod'; import { contactFormSchema } from '$lib/schema'; export const actions: Actions = { contact: async ({ request }) => { const form = await request.formData(); const formEntries = Object.fromEntries(form); try { const { email, message } = contactFormSchema.parse(formEntries); // Remember to obfuscate any real, personally identifiable information (PII) from logs, // which unauthorised personnel might have access to. console.log({ email, message }); throw redirect(303, '/'); } catch (error: unknown) { if (error instanceof ZodError) { const errors = error.flatten(); const { email, message } = formEntries; const { fieldErrors } = errors; return fail(400, { email: typeof email === 'string' ? email : '', message: typeof message === 'string' ? message : '', error: { ...(fieldErrors?.email ? { field: 'email', message: fieldErrors.email[0] } : {}), ...(fieldErrors?.message ? { field: 'message', message: fieldErrors.message[0] } : {}) } }); } } } };
We need zero changes in the front end code, as the return value maintains the earlier format.
- we validate the form entries in line
-
⚾️ Rethrow unhandled errors in SvelteKit handlers. This is possibly the easiest pitfall to fall into! You might code everything up, test it, see the form submitting just fine, but then not get the expected redirect to the thank-you page! This comes from the way SvelteKit manages redirects. Like Remix, it throws redirects. In the Zod code above, we had to catch an error. The missing line, though, is a rethrow of any errors we did not handle. Without adding that rethrow, we will catch the redirect in line
18
and just swallow it, preventing Svelte from redirecting. An easy fix is to rethrow any errors which we do not handle:
import { fail, redirect } from '@sveltejs/kit'; import type { Actions } from './$types'; import { ZodError } from 'zod'; import { contactFormSchema } from '$lib/schema'; export const actions: Actions = { contact: async ({ request }) => { const form = await request.formData(); const formEntries = Object.fromEntries(form); try { const { email, message } = contactFormSchema.parse(formEntries); // Remember to obfuscate any real, personally identifiable information (PII) from logs, // which unauthorised personnel might have access to. console.log({ email, message }); throw redirect(303, '/'); } catch (error: unknown) { if (error instanceof ZodError) { /* TRUNCATED... */ return fail(400, { /* TRUNCATED... */ }); } throw error; } } };
If there is a Zod validation error, the code returns early (from line
22
), so the rethow in line24
is not triggered. Importantly, that rethrow will be invoked on the happy path, though! 🔐 Help users follow security best practices. For signup and login, consider implementing multifactor authentication (MFA), to help protect users from credential stuffing attacks. Also encourage them to set strong and unique passwords. See more best-practices for SvelteKit login forms.
🙌🏽 SvelteKit Form Example: Wrapping Up
In this post, we saw a concrete SvelteKit form example, as well as 10 mistakes to avoid. More specifically, we saw:
- how to pass form data from the client to the server;
- how to return feedback on invalid fields from the server, repopulating valid fields; and
- debugging a common issue where redirects do not work on SvelteKit server handlers**.
Please see the full repo code on the Rodney Lab GitHub repo. The repo includes complete working examples from various stages in the journey above. I do hope you have found this post useful and can use the code in your own SvelteKit project. Let me know if you have any suggestions for improvements. Drop a comment below or reach out on other channels.
🙏🏽 SvelteKit Form Example: Feedback
If you have found this post useful, see links below for further related content on this site. I do hope you learned one new thing from the video. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on Twitter, giving me a mention, so I can see what you did. Finally, be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as Search Engine Optimization among other topics. Also, subscribe to the newsletter to keep up-to-date with our latest projects.