SvelteKit Form Example with 10 Mistakes to Avoid

Rodney Lab - Aug 18 '23 - - Dev Community

🤞🏽 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

SvelteKit Form Example: screen capture shows a contact form with email and message fields, and a send button.  All fields are blank.

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>
Enter fullscreen mode Exit fullscreen mode

+page.svelte: what we Have so Far

  • basic form using web standards
  • input fields have corresponding label elements, with for value matching the input id
  • 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, '/');
    }
};
Enter fullscreen mode Exit fullscreen mode

+page.server.ts: what we Have so Far

  • The handler is named default in line 5, because there is no action attribute on the form element in the Svelte markup. We see how to use named handlers later.
  • email & message in form.get method calls (lines 7 & 8) match the name attribute value on the input and textarea elements in the Svelte markup.
  • Working in TypeScript, form elements are of type FormDataEntryValue, and we should narrow the type to a more useful string, ahead of processing (line 10).

+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, '/');
    }
};
Enter fullscreen mode Exit fullscreen mode

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.

  1. ⛔️ 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.

  2. 📮 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 the POST method on the form element. Use GET when the user just needs to request a document. See MDN HTTP request method docs

  3. 🫱🏾‍🫲🏼 Add an action attribute to the form element for named handlers. Above, we used the default handler in the server action code. You might want to name your handlers, and if you do, you must include an action 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... */
           }
        }
    
  4. 🫣 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.

  5. 💯 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 the fail 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 custom error object which shows which field is awry and what the issue is.

  6. 🤗 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 and aria-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 and message variables returned from the action available on the frontend, via export 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 lines 29 – 31. Notice, we will display the exact message set on the server, saving us re-creating a message. The id on the small element in line 30 is important to link the erroneous field, using an aria-describedby attribute in line 27. Code for the message input is similar.

  7. 🙏🏽 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>
    
  8. 🏋🏽 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 and message. 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 expect email to be a string, and required_error is the error message we want to emit, when the email field is missing. .email() (line 6) 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, for message, we set min and max 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.
    SvelteKit Form Example: screen capture shows a contact form with email and message fields.  The email field contains the text "example.com" and has a feedback message below, which reads "Check your email address.".  The Message field contains the text "Just wanted to say you are doing a terrific job!" and has no feedback error message.

  9. ⚾️ 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 line 24 is not triggered. Importantly, that rethrow will be invoked on the happy path, though!

  10. 🔐 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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .