Willem de Vries
Nov 29 ● 5 min read
Validate forms client and server side with minimal effort
This is part 2 in the series for getting you up and running with SvelteKit. In the previous blog, we set up a fresh new project and added Tailwind CSS. In this installment we’ll build a simple form and take a look at how we can handle validation. I continued on the foundation of the previous blog, but you can follow along regardless.
Below is a screenshot of what we will be creating. It’s a simple registration form that currently just logs the output after clicking the “Create Account” button.
To build this, we make use of two libraries: Felte and Zod. Felte is a form helper library that works with Svelte and Zod is a popular validation library for Typescript.
To get things up and running, we'll need to install a few libraries. We need Felte and Zod, plus a few helper libraries from Felte. Below is the command:
npm install --save-dev felte zod @felte/validator-zod @felte/reporter-svelte
Let’s start by creating a new folder with a few files inside. First, create a “register” folder in the src/routes
directory. In the “register” folder, create three files:
+page.svelte
+page.server.ts
register.schema.ts
Refer to the screenshot below to see the end result.
I’ll go by the files one by one and explain their contents as we go through them.
This is the client side page that gets rendered to the user. In the script we initialize a form using Felte. Normally this is where you would expect properties defining your form fields, but these get inferred by Felte. This is because we specified use:form
in the <form>
tags.
The form itself is just a regular HTML form with a method, a couple of fields and a submit button. We haven’t specified a form action, because we’re using the default (that points to itself). The styling is done with Tailwind CSS.
The validation messages get rendered with the help of a Felte component (we installed it with @felte/reporter-svelte
). There are multiple options available and you can even build your own. In this example I just used the default component and gave it some Tailwind classes to make the text a bit smaller and color it red. In the future I might create my own component, but for now I didn’t see the need.
While building this out I came up with a neat little trick. By default, the <ValidationMessage>
component only shows a message below the input (or wherever you place it). But I was missing a red border on the inputs themselves, so I wrapped the <ValidationMessage>
component around the <input>
and used the message
to toggle a custom error class. This class is defined at the bottom of the file. It uses @apply
to “merge” multiple Tailwind classes into a single custom class.
Finally, when clicking the submit button, Felte validates the form using our Zod schema (register.schema.ts
). If it encounters any errors, these will be displayed to the user with the aforementioned component. And if there are no errors, the form is submitted to the +page.server.ts
endpoint. We’ll take a look at that file next.
<script lang="ts">
import { createForm } from 'felte';
import { reporter, ValidationMessage } from '@felte/reporter-svelte';
import { validator } from '@felte/validator-zod';
import { registerSchema, type RegisterSchema } from './register.schema';
const { form } = createForm<RegisterSchema>({
extend: [validator({ schema: registerSchema }), reporter()]
});
</script>
<section class="p-8 max-w-md">
<h1 class="text-xl font-medium leading-6 text-gray-900">Register</h1>
<form use:form method="post" class="my-4">
<div class="grid grid-cols-1">
<label for="email" class="block text-sm font-medium text-gray-700">E-mail address</label>
<ValidationMessage for="email" level="error" let:messages={message}>
<input
type="text"
name="email"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class:error={message}
/>
<span class="mt-2 text-sm text-red-600">{message || ''}</span>
</ValidationMessage>
</div>
<div class="grid grid-cols-1 mt-4">
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<ValidationMessage for="password" let:messages={message}>
<input
type="password"
name="password"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
class:error={message}
/>
<span class="mt-2 text-sm text-red-600">{message || ''}</span>
</ValidationMessage>
</div>
<div class="flex justify-end">
<button
type="submit"
class="rounded-md border border-transparent bg-indigo-600 py-2 px-4 mt-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>Create Account
</button>
</div>
</form>
</section>
<style lang="css">
.error {
@apply text-red-900;
@apply border-red-300;
@apply placeholder-red-300;
@apply focus:ring-red-500;
@apply focus:border-red-500;
@apply focus:outline-none;
}
</style>
In this endpoint we have a default action that just parses the form data into an object. After that the object is validated using Zod and the register schema we used in the client side page as well.
If it passes, we log the result (which is just the object we passed in) and if it does not, we log the error object. In a real world application you would pass the error object back to the client, where they can correct any mistakes and resubmit the form. I still need to figure out how to do that elegantly, so maybe I’ll get back to that in a future post.
import type { Actions } from './$types';
import { registerSchema } from './register.schema';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const entries = formData.entries();
const data = Object.fromEntries(entries);
try {
const results = registerSchema.parse(data);
console.log(results);
} catch (error) {
console.log(error);
}
}
};
This file just contains a very basic Zod validation schema. There are plenty of options here, but I just picked a few to highlight here. For each option, you can specify a custom message. For example you could change the min(1)
message to give a required error, instead the default message of “String must contain at least 1 character(s)”.
I encourage you to dive into the Zod documentation and experiment with some options. You can make this file as complex as you like, since you only need to write it once for both the client and server side.
import { z } from 'zod';
export const registerSchema = z.object({
email: z
.string()
.email({ message: 'Invalid email'})
.min(1, { message: 'Email is required' }),
password: z.string().min(8, { message: 'Password must contain at least 8 characters' })
});
export type RegisterSchema = z.infer<typeof registerSchema>;
I hope that this basic example has inspired you to try out the combination of Felte and Zod in your next project. For this blog I researched a lot of options and concluded for myself that this combination is the perfect balance between simplicity, features, bundle size, reusability and developer experience.
You can view the first blog post in our svelte series here