Okupter

Form validation with SvelteKit and Zod

Justin's profile pic

Justin Ahinon

Last updated on

Form validation with SvelteKit and Zod

If you have ever built a form with SvelteKit, you already know that the experience is quite amazing. The framework provides a lot of tools to help you build forms, make requests, and pass data between the client and the server. You can make that experience even better by using type safe validation for your forms with Zod.

But first, what is Zod?

You can think of Zod as a schema builder and validation for your JavaScript/TypeScript applications. It’s a very developer-friendly tool that provides a ton of features to help you safely consume and validate data.

Here is a quick example of what Zod can do for you:

<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;zod&quot;</span>;

<span class="hljs-comment">// Declare a schema</span>
<span class="hljs-keyword">const</span> userSchema = z.<span class="hljs-title function_">object</span>({
  <span class="hljs-attr">name</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">1</span>),
  <span class="hljs-attr">age</span>: z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">18</span>),
  <span class="hljs-attr">email</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">email</span>(),
});

<span class="hljs-comment">// You can now comsume/parse your schema</span>
<span class="hljs-keyword">const</span> validUserData = {
    <span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;John&#x27;</span>,
    <span class="hljs-attr">age</span>: <span class="hljs-number">18</span>,
    <span class="hljs-attr">email</span>: <span class="hljs-string">&#x27;test@example.com&#x27;</span>
};

<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(userSchema.<span class="hljs-title function_">parse</span>(validUserData));

<span class="hljs-comment">// This will output:</span>
<span class="hljs-comment">// { name: &#x27;John&#x27;, age: 18, email: &#x27;test@example.com&#x27; }</span>

<span class="hljs-keyword">const</span> invalidUserData = {
    <span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;John&#x27;</span>,
    <span class="hljs-attr">age</span>: <span class="hljs-number">17</span>, <span class="hljs-comment">// age is less than 18</span>
    <span class="hljs-attr">email</span>: <span class="hljs-string">&#x27;myemail&#x27;</span> <span class="hljs-comment">// email is not valid</span>
};

<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(userSchema.<span class="hljs-title function_">parse</span>(invalidUserData));

<span class="hljs-comment">// This will throw a ZodError that looks like this:</span>
<span class="hljs-comment">// ZodError: [</span>
<span class="hljs-comment">//   {</span>
<span class="hljs-comment">//     &quot;code&quot;: &quot;too_small&quot;,</span>
<span class="hljs-comment">//     &quot;minimum&quot;: 18,</span>
<span class="hljs-comment">//     &quot;type&quot;: &quot;number&quot;,</span>
<span class="hljs-comment">//     &quot;inclusive&quot;: true,</span>
<span class="hljs-comment">//     &quot;message&quot;: &quot;Number must be greater than or equal to 18&quot;,</span>
<span class="hljs-comment">//     &quot;path&quot;: [</span>
<span class="hljs-comment">//       &quot;age&quot;</span>
<span class="hljs-comment">//     ]</span>
<span class="hljs-comment">//   },</span>
<span class="hljs-comment">//   {</span>
<span class="hljs-comment">//     &quot;validation&quot;: &quot;email&quot;,</span>
<span class="hljs-comment">//     &quot;code&quot;: &quot;invalid_string&quot;,</span>
<span class="hljs-comment">//     &quot;message&quot;: &quot;Invalid email&quot;,</span>
<span class="hljs-comment">//     &quot;path&quot;: [</span>
<span class="hljs-comment">//       &quot;email&quot;</span>
<span class="hljs-comment">//     ]</span>
<span class="hljs-comment">//   }</span>
<span class="hljs-comment">// ]</span>
<span class="hljs-comment">// ..</span>

You can use a try/catch block to catch the error to avoid your application crashing or use Zod safeParse to avoid throwing an error.

Now that you have a basic understanding of what Zod is, let’s see how we can use it to validate our SvelteKit forms.

What are we building?

For the purpose of this post, we will build a simple software engineers talent directory application. Users will be able to use a form to add their profile to the directory. To keep things simple and focused on the topic, we will not handle things like authentication and authorization.

Basic structure for our SvelteKit project

The GitHub repository for this post is publicly available here.

Our project basically consists of a root +page.svelte that contains our app form and a +page.server.ts server route to handle the form submission and validation.

<span class="hljs-comment">&lt;!-- src/routes/+page.svelte --&gt;</span><span class="language-xml">

<span class="hljs-tag">&lt;<span class="hljs-name">form</span> <span class="hljs-attr">use:enhance</span>&gt;</span>
	<span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;text&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;name&quot;</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&quot;Name&quot;</span> <span class="hljs-attr">aria-label</span>=<span class="hljs-string">&quot;Name&quot;</span> <span class="hljs-attr">required</span> /&gt;</span>
	<span class="hljs-tag">&lt;<span class="hljs-name">select</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;role&quot;</span> <span class="hljs-attr">aria-label</span>=<span class="hljs-string">&quot;Role&quot;</span> <span class="hljs-attr">required</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;frontend-engineer&quot;</span>&gt;</span>Frontend Engineer<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;backend-engineer&quot;</span>&gt;</span>Backend Engineer<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;fullstack-engineer&quot;</span>&gt;</span>Fullstack Engineer<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;architect&quot;</span>&gt;</span>Architect<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
	<span class="hljs-tag">&lt;/<span class="hljs-name">select</span>&gt;</span>
	<span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;email&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;email&quot;</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&quot;Email&quot;</span> <span class="hljs-attr">required</span> /&gt;</span>

	<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;submit&quot;</span>&gt;</span>Submit<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span>

Notice here that I used the use:enhance to enable progressive enhancement for our form. All the forms fields are required, and we will use Zod to make sure this is respected.

In my page server route, I declare the schema for a talent profile.

<span class="hljs-comment">// src/routes/+page.server.ts</span>

<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;zod&#x27;</span>;

<span class="hljs-keyword">const</span> talentSchema = z.<span class="hljs-title function_">object</span>({
	<span class="hljs-attr">name</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">trim</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">1</span>),
	<span class="hljs-attr">role</span>: z.<span class="hljs-title function_">enum</span>([<span class="hljs-string">&#x27;frontend-engineer&#x27;</span>, <span class="hljs-string">&#x27;backend-engineer&#x27;</span>, <span class="hljs-string">&#x27;fullstack-engineer&#x27;</span>, <span class="hljs-string">&#x27;architect&#x27;</span>]),
	<span class="hljs-attr">email</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">trim</span>().<span class="hljs-title function_">email</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">1</span>)
});

It is also possible to customize the error messages that Zod returns, like this:

z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">5</span>, { <span class="hljs-attr">message</span>: <span class="hljs-string">&quot;Must be 5 or more characters long&quot;</span> });

I use .trim() to remove any whitespace from the string before validating it. We also use .min(1) to make sure the string is not empty.

I can now use these in my SvelteKit form actions to handle the form submission and validation.

<span class="hljs-comment">// src/routes/+page.server.ts</span>

<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">Actions</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./$types&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-attr">actions</span>: <span class="hljs-title class_">Actions</span> = {
	<span class="hljs-attr">default</span>: <span class="hljs-keyword">async</span> (event) =&gt; {
		<span class="hljs-keyword">const</span> formDataa = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(<span class="hljs-keyword">await</span> event.<span class="hljs-property">request</span>.<span class="hljs-title function_">formData</span>());
		<span class="hljs-keyword">const</span> talentData = talentSchema.<span class="hljs-title function_">safeParse</span>(formDataa);
		<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(talentData);
	}
};

In the snippet above, we get the form data from the request, and safely parse it using our predefined schema (remember, safe parsing will not throw an error).

Here’s what the console output looks like after submitting correct data through the form:

<span class="hljs-punctuation">{</span>
  success<span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span>
  data<span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> name<span class="hljs-punctuation">:</span> &#x27;Justin&#x27;<span class="hljs-punctuation">,</span> role<span class="hljs-punctuation">:</span> &#x27;backend-engineer&#x27;<span class="hljs-punctuation">,</span> email<span class="hljs-punctuation">:</span> &#x27;me@test.com&#x27; <span class="hljs-punctuation">}</span>
<span class="hljs-punctuation">}</span>

We can simulate incorrect data submission by removing the required attribute from the name and email fields to see what’s outputted in the console:

<span class="hljs-punctuation">{</span>
  success<span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span>
  error<span class="hljs-punctuation">:</span> ZodError<span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
    <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">&quot;code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;too_small&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;minimum&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;string&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;inclusive&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;String must contain at least 1 character(s)&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;path&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
        <span class="hljs-string">&quot;name&quot;</span>
      <span class="hljs-punctuation">]</span>
    <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">&quot;validation&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;email&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;invalid_string&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;Invalid email&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;path&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
        <span class="hljs-string">&quot;email&quot;</span>
      <span class="hljs-punctuation">]</span>
    <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">&quot;code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;too_small&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;minimum&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;string&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;inclusive&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;String must contain at least 1 character(s)&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;path&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
        <span class="hljs-string">&quot;email&quot;</span>
      <span class="hljs-punctuation">]</span>
    <span class="hljs-punctuation">}</span>
  <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
  errors<span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span> <span class="hljs-punctuation">[</span>Object<span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-punctuation">[</span>Object<span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-punctuation">[</span>Object<span class="hljs-punctuation">]</span> <span class="hljs-punctuation">]</span>
  ...
<span class="hljs-punctuation">}</span>

We now have a bit of idea of how we will use the safeParse method to validate our form data.

Handling form validation errors

Since Zod returns a success boolean and an errors array, we can use these to create the necessary logic to handle form validation errors.

<span class="hljs-comment">// src/routes/+page.server.ts</span>

<span class="hljs-keyword">import</span> { invalid } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@sveltejs/kit&#x27;</span>;

<span class="hljs-keyword">if</span> (!talentData.<span class="hljs-property">success</span>) {
	<span class="hljs-comment">// Loop through the errors array and create a custom errors array</span>
	<span class="hljs-keyword">const</span> errors = talentData.<span class="hljs-property">error</span>.<span class="hljs-property">errors</span>.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {
		<span class="hljs-keyword">return</span> {
			<span class="hljs-attr">field</span>: error.<span class="hljs-property">path</span>[<span class="hljs-number">0</span>],
			<span class="hljs-attr">message</span>: error.<span class="hljs-property">message</span>
		};
	});
	
	<span class="hljs-keyword">return</span> <span class="hljs-title function_">invalid</span>(<span class="hljs-number">400</span>, { <span class="hljs-attr">error</span>: <span class="hljs-literal">true</span>, errors });
}

The invalid() function comes in very handy here to return a validation error object to the client.

In the current state, we will not see any behavioral change in the client side after we submit incorrect data through the form. That’s because, even if we return a validation error object, our client doesn’t know yet how to handle it.

Let’s fix that by adding a bit of client side logic.

<span class="hljs-comment">&lt;!-- src/routes/+page.svelte --&gt;</span><span class="language-xml">

<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">&quot;ts&quot;</span>&gt;</span><span class="language-javascript">
	<span class="hljs-keyword">import</span> type </span></span><span class="language-javascript">{ <span class="hljs-title class_">ActionData</span> }</span><span class="language-xml"><span class="language-javascript"> <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./$types&#x27;</span>;
	<span class="hljs-keyword">import</span> </span></span><span class="language-javascript">{ enhance }</span><span class="language-xml"><span class="language-javascript"> <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$app/forms&#x27;</span>;
	<span class="hljs-keyword">export</span> <span class="hljs-keyword">let</span> <span class="hljs-attr">form</span>: <span class="hljs-title class_">ActionData</span>;

	<span class="hljs-attr">$</span>: <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(form);
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>

Here, we use the form variable (it needs to be named form for the form actions to work) to log the form data to the console. We also use the $: syntax to log the form data to the console whenever it changes.

Here’s what console.log() gives us:

Form date on the frontend

Let’s generate and display some error messages based on the form data.

<span class="hljs-comment">&lt;!-- src/routes/+page.svelte --&gt;</span><span class="language-xml">

<span class="hljs-tag">&lt;<span class="hljs-name">form</span> <span class="hljs-attr">use:enhance</span>&gt;</span>
	...

	</span><span class="language-javascript">{</span><span class="hljs-keyword">#if</span><span class="language-javascript"> form?.<span class="hljs-property">error</span>}</span><span class="language-xml">
		<span class="hljs-tag">&lt;<span class="hljs-name">ul</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;notice-error&quot;</span>&gt;</span>
			</span><span class="language-javascript">{</span><span class="hljs-keyword">#each</span><span class="language-javascript"> form.<span class="hljs-property">errors</span> <span class="hljs-keyword">as</span> error}</span><span class="language-xml">
				<span class="hljs-tag">&lt;<span class="hljs-name">li</span>&gt;</span></span><span class="language-javascript">{error.<span class="hljs-property">message</span>}</span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">li</span>&gt;</span>
			</span><span class="language-javascript">{</span><span class="hljs-keyword">/each</span><span class="language-javascript">}</span><span class="language-xml">
		<span class="hljs-tag">&lt;/<span class="hljs-name">ul</span>&gt;</span>
	</span><span class="language-javascript">{</span><span class="hljs-keyword">/if</span><span class="language-javascript">}</span><span class="language-xml">
<span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span>

We now get these nice error notices after submitting incorrect data through the form:

Form error notices

Going further

We can go further by extracting the form errors notices into a standalone component and use JavaScript to append each error message next to the corresponding input field.

Wrapping up

Out of the box, SvelteKit already provides a ton of useful features to handle forms submission and validation. Adding Zod to that mix makes it even more powerful. The possibilities are endless.