Working With Forms In SvelteKit

Table of Contents

Previously

This is part of a SvelteKit series and while each part is meant to be self-contained here are the previous parts in case you want to catch up:

Setup

I’m going to initialize a skeleton SvelteKit project using TypeScript.

terminal
# install SvelteKit
npm create svelte@latest

# install dependencies
npm i

# run development server
npm run dev

I’m going to add a root layout with some global styles using Pico.

src/routes/+layout.svelte
<slot />

<style>
  @import 'https://unpkg.com/@picocss/pico@latest/css/pico.min.css';

  :global(body) {
    padding: 2rem;
  }

  :global(input, button) {
    border-radius: 1rem;
  }
</style>

Creating A Fake Database

I’m going to create a server-only module inside lib that’s going to act as a fake database for the to-do list.

src/lib/server/database.ts
type Todo = {
  id: number
  text: string
  completed: boolean
}

let todos: Todo[] = [
  {
    id: Date.now(),
    text: 'Learn how forms work',
    completed: false
  }
]

export function getTodos() {
  return todos
}

export function addTodo(text: string) {
  const todo: Todo = {
    id: Date.now(),
    text,
    completed: false
  }
  todos.push(todo)
}

export function removeTodo(id: number) {
  todos = todos.filter((todo) => todo.id !== id)
}

export function clearTodos() {
  todos = []
}

Reintroduction To Forms

A form is a way to exchange information between the browser and server — it’s just a container for form controls with some optional attributes to configure how the form behaves.

routes/+page.svelte
<form method="GET" action="/login">
  <input type="text" name="user" />
  <input type="password" name="password" />
  <button type="submit">Login</button>
</form>

The action attribute defines the location (URL) where the form’s collected data should be sent and the method attribute defines which HTTP method to send the data with. The form attributes are optional. The default form method is GET and if you don’t include action it’s going to make the request to the same page.

The GET method requests a resource from the server and appends the form data at the end of the URL like http://example.com/?user=test&password=1234 since the body is empty and it not great for sending a large amount of data and is not secure.

routes/+page.svelte
<form method="POST" action="/login">
  <input type="text" name="user" />
  <input type="password" name="password" />
  <button type="submit">Login</button>
</form>

The POST method is similar to GET but it can return a resource depending on the data sent in the request body of the HTTP request — no data is appended to the URL and the data is included in the body instead.

In our example the form is going to send data to the URL using the HTTP POST method. The server is going to receive the data as a list of key: value pairs contained in the HTTP request.

If you submit the form above you can see the parsed form data from user=test&password=1234 under payload in the network tab of the developer tools.

request
user: test
password: 1234

The name attribute for each form control is important, so the browser knows which name to give each piece of data.

Working With Forms Using API Endpoints

This part is important for understanding how forms work and it’s going to give you more appreciation when you learn how simple SvelteKit makes working with forms in the next section.

I’m going to create a routes/todos folder with some route files.

todos
routes
└── todos
    ├── +page.server.ts
    ├── +page.svelte
    └── +server.ts

First I’m going to get the data for the page which is the currently empty to-do list.

routes/todos/+page.server.ts
import type { PageServerLoad } from './$types'

import { getTodos } from '$lib/server/database'

export const load: PageServerLoad = async () => {
  const todos = getTodos()
  return { todos }
}

Let’s loop over the to-do items and add the form.

routes/todos/+page.svelte
<script lang="ts">
  import type { PageData } from './$types'

  export let data: PageData

  // todo
  async function addTodo(event: Event) {}

  // todo
  async function removeTodo(event: Event) {}
</script>

<ul>
  {#each data.todos as todo}
    <li>
      <span>{todo.text}</span>
      <form on:submit|preventDefault={removeTodo} method="POST">
        <input type="hidden" name="id" value={todo.id} />
        <button class="delete" type="submit"></button>
      </form>
    </li>
  {/each}
</ul>

<form on:submit|preventDefault={addTodo} method="POST">
  <input type="text" name="todo" />
  <button type="submit">+ Add Todo</button>
</form>

<style>
  ul {
    padding: 0;
  }

  li {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  span {
    text-transform: capitalize;
  }

  .delete {
    margin: 0;
    background: none;
    border: none;
  }

  .error {
    color: tomato;
  }
</style>

The action attribute is optional because we’re going to use the same endpoint for the form.

🐿️ You might be more used to JavaScript instead of using forms and it might feel weird using forms for what might seem easier to use a button for but it’s perfectly normal and you should embrace it (you’re going to see how awesome it is).

I’m going to create a POST and DELETE function inside +server.ts that should get the data from the form and do the appropriate action — I’m going to use a formData object to send success and errors to update the UI for the user.

routes/todos/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

import { addTodo, removeTodo } from '$lib/server/database'

type Data = {
  success: boolean
  errors: Record<string, string>
}

export const POST: RequestHandler = async ({ request }) => {
  const formData = await request.formData()
  const todo = String(formData.get('todo'))

  const data: Data = {
    success: false,
    errors: {}
  }

  if (!todo) {
    data.errors.todo = 'required'
    return json(data, { status: 400 })
  }

  addTodo(todo)
  data.success = true

  return json(data)
}

export const DELETE: RequestHandler = async ({ request }) => {
  const formData = await request.formData()
  const todoId = Number(formData.get('id'))

  removeTodo(todoId)

  return json({ success: true })
}

For the todoId I used a hidden input field to get the id for the to-do when looping through the to-do list in the template.

Let’s go back to +page.svelte and create addTodo and removeTodo functions and update the UI based on the response.

routes/todos/+page.svelte
<script lang="ts">
  import { invalidateAll } from '$app/navigation'
  import type { PageData } from './$types'

  // we need the same type
  type Data = {
    success: boolean
    errors: Record<string, string>
  }

  export let data: PageData

  // used in the template
  let form: Data

  async function addTodo(event: Event) {
    const formEl = event.target as HTMLFormElement
    const data = new FormData(formEl)

    // you can see everything about the form
    console.dir(form)

    const response = await fetch(formEl.action, {
      method: 'POST',
      body: data
    })
    const responseData = await response.json()

    // { success: true, errors: {} } object
    form = responseData

    // reset form
    formEl.reset()

    // rerun `load` function for the page
    await invalidateAll()
  }

  async function removeTodo(event: Event) {
    const formEl = event.target as HTMLFormElement
    const data = new FormData(formEl)

    // forms only support `GET` and `POST` methods but
    // SvelteKit maps this to the `DELETE` function
    const response = await fetch(formEl.action, {
      method: 'DELETE',
      body: data
    })

    await invalidateAll()
  }
</script>

<ul>
  {#each data.todos as todo}
    <li>
      <span>{todo.text}</span>
      <form on:submit|preventDefault={removeTodo} method="POST">
        <input type="hidden" name="id" value={todo.id} />
        <button class="delete" type="submit"></button>
      </form>
    </li>
  {/each}
</ul>

<form on:submit|preventDefault={addTodo} method="POST">
  <input type="text" name="todo" />
  {#if form?.errors?.todo}
    <p class="error">This field is required</p>
  {/if}

  <button type="submit">+ Add Todo</button>
</form>

{#if form?.success}
  <p>Added todo! 🥳</p>
{/if}

If you add or remove a to-do you can see the fetch request and the payload in the network tab — the request also returns a JSON response that has success and errors which is then used on the front-end to update the UI for the user.

This taught us a lot about about forms but…

This is not a great user and developer experience because:

  • It only works with JavaScript
  • You had to invent your own wonky validation
  • Using fetch and invalidate is weird and the framework should do more for you
  • Repeating types feels bad

This is why you should use SvelteKit form actions and in the next section I’m going to show you why they’re so awesome.

Working With Forms The SvelteKit Way Using Form Actions

Inside a standalone +server.ts endpoint you can use functions that map to HTTP verbs like GET or POST but form actions take this idea a step further and you can define methods that map to an action inside a +page.server.ts file.

+page.server.ts
import type { Actions } from './$types'

export const actions: Actions = {
  default: async (event) => {
  // ...
  }
}

If you don’t define an action attribute you can use the default method but for multiple actions it’s replaced by named actions like action="?/addTodo".

+page.svelte
<form method="POST">
  <!-- ... -->
</form>

Actions always use POST requests because GET requests shouldn’t have side-effects and they can be invoked from other pages.

+page.svelte
<form method="POST" action="/todos?/addTodo">
  <!-- ... -->
</form>

If you need to POST the same form data to a different action you can also use the formaction attribute.

+page.svelte
<form method="POST" action="?/addTodo">
  <!-- ... -->
  <button>+ Add todo</button>
  <button formaction="?/clearTodos">Clear</button>
  </form>

I’m going to convert the previous example to use form actions. First I’m going to delete the +server.ts file since you don’t need it, so you should have this folder structure.

todos
routes
└── todos
    ├── +page.server.ts
    └── +page.svelte

Fetching data for the page using the load function is the same but now you can also export an actions object from +page.server.ts.

Let’s start with creating actions for adding, removing and clear to-do items.

routes/todos/+page.server.ts
import type { PageServerLoad } from './$types'
import { fail, type Actions } from '@sveltejs/kit'

import { addTodo, clearTodos, getTodos, removeTodo } from '$lib/server/database'

export const load: PageServerLoad = async () => {
  const todos = getTodos()
  return { todos }
}

export const actions: Actions = {
  addTodo: async ({ request }) => {
    const formData = await request.formData()
    const todo = String(formData.get('todo'))

    if (!todo) {
      return fail(400, { todo, missing: true })
    }

    addTodo(todo)

    return { success: true }
  },
  removeTodo: async ({ request }) => {
    const formData = await request.formData()
    const todoId = Number(formData.get('id'))
    
    removeTodo(todoId)
    
    return { success: true }
  },
  clearTodos: () => {
    clearTodos()
  }
}

You can already see how much less code you have to write and SvelteKit handles returning validation errors using the fail function that can have the data you can use for the input value to get around the form reset.

The action responds with data that’s available through the form property and $page.form store and reruns the load function for the page.

routes/todos/+page.svelte
<script lang="ts">
  import type { ActionData, PageData } from './$types'

  export let data: PageData
  export let form: ActionData
</script>

<ul>
  {#each data.todos as todo}
    <li>
      <span>{todo.text}</span>
      <form method="POST" action="?/removeTodo">
        <input type="hidden" name="id" value={todo.id} />
        <button class="delete" type="submit"></button>
      </form>
    </li>
  {/each}
</ul>

<form method="POST" action="?/addTodo">
  <input type="text" name="todo" value={form?.todo ?? ''} />
  {#if form?.missing}
    <p class="error">This field is required</p>
  {/if}

  <button type="submit">+ Add Todo</button>

  <button	formaction="?/clearTodos" class="secondary" type="submit">
    Clear
  </button>
</form>

{#if form?.success}
  <p>Added todo! 🥳</p>
{/if}

<style>
  ul {
    padding: 0;
  }

  li {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  span {
    text-transform: capitalize;
  }

  .delete {
    margin: 0;
    background: none;
    border: none;
  }

  .error {
    color: tomato;
  }
</style>

🐿️ The SvelteKit language server is doing a lot of work under the hood and sometimes you might have a problem with the generated types in which case you can press Ctrl + P and type > Svelte: Restart Language Server but if that doesn’t work restart the development server or your editor as a last resort.

If you look at the payload in the network tab when adding or removing a to-do you’re going to see the parsed query string and form data.

Actions might look like magic but they’re just a URL that invokes a function. If you take / out of ?/addTodo you end up ?addTodo which is just a search param but / lets SvelteKit know you meant to use an action.

Progressive Form Enhancement

Try to add or remove a to-do item from the list and pay attention to the icon in the browser tab and look at your network tab.

You’re going to notice each time you add or remove a to-do the page reloads. This is the default form behavior once you submit it — this means the form works without JavaScript!

The point is not that your site should work without JavaScript but before it because JavaScript can fail loading for various reasons and this is what makes your app more resilient — instead of relying on JavaScript you can use it to progressive enhance the form and the user experience when JavaScript is available on the page.

Remember the first example how we had to do everything by hand? SvelteKit does that for you and wraps everything in a neat use:enhance Svelte action (unrelated to form actions) that does the same thing.

routes/todos/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms'
  import type { ActionData, PageData } from './$types'

  export let data: PageData
  export let form: ActionData
</script>

<ul>
  {#each data.todos as todo}
    <li>
      <span>{todo.text}</span>
      <form method="POST" action="?/removeTodo" use:enhance>
        <input type="hidden" name="id" value={todo.id} />
        <button class="delete" type="submit"></button>
      </form>
    </li>
  {/each}
</ul>

<form method="POST" action="?/addTodo" use:enhance>
  <input type="text" name="todo" value={form?.todo ?? ''} />
  {#if form?.missing}
    <p class="error">This field is required</p>
  {/if}

  <button type="submit">+ Add Todo</button>

  <button	formaction="?/clearTodos" class="secondary" type="submit">
    Clear
  </button>
</form>

{#if form?.success}
  <p>Added todo! 🥳</p>
{/if}

<!-- ... -->

It only takes one line of code if you count the import and everything works the same but instead of reloading the page it’s going to prevent the default form behavior and use JavaScript — if you look at the network tab you should see the fetch request.

When you submit the form the use:enhance action is going to:

  • Update the form, $page.form and $page.status property
  • Reset the <form> element and rerun the load function for the page by using invalidateAll
  • Use goto for a redirect response

If you want to learn how it works you can look at form.js inside the SvelteKit repo which looks very familiar to what I showed you earlier — everything leads to this point because I want to give you perspective and understanding how the things you use work and why.

Customize The Enhance Action To Show A Loading UI

You can customize the behavior of use:enhance by providing a submit function that runs before the form is submitted and can return a callback that has access to the result.

routes/todos/+page.svelte
<script lang="ts">
  import { enhance, type SubmitFunction } from '$app/forms'
  import type { ActionData, PageData } from './$types'

  export let data: PageData
  export let form: ActionData

  const addTodo: SubmitFunction = (input) => {
    // do something before the form submits
    console.log(input)

    return async (options) => {
      // do something after the form submits
      console.log(options)
    }
  }
</script>

<!-- ... -->

The input contains:

  • action for the URL details
  • cancel to prevent the submission
  • controller is the AbortController you can use to cancel a request
  • data is the FormData object
  • form is the <form> element

The options contains:

  • action for the URL details
  • form is the <form> element
  • result which is an ActionResult object
  • update function that runs the regular logic, otherwise you would have to do it yourself

You can’t always rely on a fast response from the server in which case the user might leave your site because it looks broken.

I defined a sleep function to simulate a slow response which I’m going to use inside the addTodo action.

routes/todos/+page.server.ts
import type { PageServerLoad } from './$types'
import { fail, type Actions } from '@sveltejs/kit'

import { addTodo, getTodos, removeTodo } from '$lib/server/database'

export const load: PageServerLoad = async () => {
  const todos = getTodos()
  return { todos }
}

async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

export const actions: Actions = {
  addTodo: async ({ request }) => {
    const formData = await request.formData()
    const todo = String(formData.get('todo'))

    if (!todo) {
      return fail(400, { todo, missing: true })
    }

    await sleep(2000)

    addTodo(todo)

    return { success: true }
  },
  removeTodo: async ({ request }) => {
    const formData = await request.formData()
    const todoId = Number(formData.get('id'))
    removeTodo(todoId)
  },
  clearTodos: () => {
    clearTodos()
  }
}

Using what we know I’m going to set loading = true before the form submits and when it’s done we can set loading = false.

routes/todos/+page.svelte
<script lang="ts">
  import { enhance, type SubmitFunction } from '$app/forms'
  import type { ActionData, PageData } from './$types'

  export let data: PageData
  export let form: ActionData

  let loading = false

  const addTodo: SubmitFunction = () => {
    loading = true

    return async ({ update }) => {
      loading = false
      await update()
    }
  }
</script>

<ul>
  {#each data.todos as todo}
    <li>
      <span>{todo.text}</span>
      <form method="POST" action="?/removeTodo" use:enhance>
        <input type="hidden" name="id" value={todo.id} />
        <button class="delete" type="submit"></button>
      </form>
    </li>
  {/each}
</ul>

<form method="POST" action="?/addTodo" use:enhance={addTodo}>
  <input type="text" name="todo" value={form?.todo ?? ''} />
  {#if form?.missing}
    <p class="error">This field is required</p>
  {/if}

  <button aria-busy={loading} class:secondary={loading} type="submit">
    {#if !loading}+ Add todo{/if}
  </button>
    
  <button	formaction="?/clearTodos" class="secondary" type="submit">
    Clear
  </button>
</form>

{#if form?.success}
  <p>Added todo! 🥳</p>
{/if}

<!-- ... -->

Awesome! 😄

You can show a toast notification when a user adds and removes a to-do item but I’m going to leave that as an exercise to the reader.

Form Validation In SvelteKit

I’m going to show you how you can validate a form using the popular schema validation library Zod (hail Zod!) and show you a couple of tricks. 🛹

I’m going to create a routes/login route using the first example but I’m going to add the required attribute for the input fields.

routes/login/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms'
</script>

<form method="POST" use:enhance>
  <input type="text" name="user" required />
  <input type="password" name="password" required />
  <button type="submit">Login</button>
</form>

The browser has some built-in validation but the problem is that anyone can be hackerman and edit client-side code and submit whatever they want — this serves more as a nicer user experience than a guard against malicious actors and that is why you should always do server-side validation.

The next part is going to make more sense if I describe the shape of the object I want for the form prop on the page.

example.ts
{
  "data": {
    "name": "",
    "email": ""
  },
  "errors": {
    "user": "required",
    "password": "required"
  }
}

As you can see there is no rule what you should return and now you can do form?.data?.value to return the input value and form?.errors?.value to provide feedback.

Let’s add the validation inside the default form action.

routes/login/+page.server.ts
import { fail, redirect, type Actions } from '@sveltejs/kit'

export const actions: Actions = {
  default: async ({ request }) => {
    // get the form data
    const formData = await request.formData()

    // get the input values
    const user = String(formData.get('user'))
    const password = String(formData.get('password'))

    // gather errors
    const errors: Record<string, unknown> = {}

    if (!user || typeof user !== 'string') {
      errors.user = 'required'
    }

    if (!password || typeof password !== 'string') {
      errors.password = 'required'
    }

    // in case of an error return the data and errors
    if (Object.keys(errors).length > 0) {
      const data = {
        data: Object.fromEntries(formData),
        errors
      }
      return fail(400, data)
    }

    // redirect the user
    throw redirect(303, '/todos')
  }
}

This can now we be used in the template.

routes/login/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms'
  import type { ActionData } from './$types'

  export let form: ActionData
</script>

<form method="POST" use:enhance>
  <input type="text" name="user" value={form?.data?.user ?? ''} />
  {#if form?.errors?.user}
    <p class="error">Name is required</p>
  {/if}

  <input type="password" name="password" value={form?.data?.password ?? ''} />
  {#if form?.errors?.password}
    <p class="error">Password is required</p>
  {/if}

  <button type="submit">Login</button>
</form>

<style>
  .error {
    color: tomato;
  }
</style>

This is great but having to get every input value and using conditional statements even for a basic form feels tedious.

This is a perfect use case for Zod that’s great at validating an unknown input. First you create a schema of the fields you expect and parse it using Zod which is going to make sure the values are correct or otherwise throw an error and you can abstract the validation logic inside a function.

🐿️ zod-form-data is a great example of using web standards because it was made for Remix but works for SvelteKit because FormData is a web standard.

I’m going to install zod and the zod-form-data package that makes it simple to parse FormData and URLSearchParams.

terminal
npm i z zod-form-data

I’m going to change the previous example to use Zod for validation.

routes/login/+page.server.ts
import { fail, redirect, type Actions } from '@sveltejs/kit'
import { zfd } from 'zod-form-data'

export const actions: Actions = {
  default: async ({ request }) => {
    // get the form data
    const formData = await request.formData()

    // define the validation schema
    const loginSchema = zfd.formData({
      user: zfd.text(),
      password: zfd.text()
    })

    // parse the validation schema
    const result = loginSchema.safeParse(formData)

    // in case of an error return the data and errors
    if (!result.success) {
      const data = {
        data: Object.fromEntries(formData),
        errors: result.error.flatten().fieldErrors
      }
      return fail(400, data)
    }

    // redirect the user
    throw redirect(303, '/todos')
  }
}

This isn’t the only way to do validation with Zod and you can read more about error handling in Zod from the docs but form validation deserves its own post.

You can abstract the validation logic inside a validation function that you can use alongside other utils and have a separate file for your validation schemas.

utils.ts
export const validate(formData: FormData, schema: Schema) {
  // ...
}

You can use it like this inside your project.

+page.server.ts
const formData = await request.formData()
const { data, errors } = validate(formData)

Advanced Enhance Action Customization

You might want to reproduce a part of the default use:enhance behavior in which case you can use applyAction.

I mentioned how actions can be invoked from other pages and in this example I want to reuse the /login endpoint in routes/+page.svelte and I copied over everything from routes/login/+page.svelte.

If you submit the form it kinda works but you don’t get the validation data back because form is never updated.

routes/+page.svelte
<script lang="ts">
  import { enhance } from '$app/forms'
  import type { ActionData } from './$types'

  export let form: ActionData
</script>

<form method="POST" action="/login" use:enhance>
  <input type="text" name="name" value={form?.data?.name ?? ''} />
  {#if form?.errors?.name}
    <p class="error">Name is required</p>
  {/if}

  <input type="email" name="email" value={form?.data.email ?? ''} />
  {#if form?.errors?.email}
    <p class="error">Email is required</p>
  {/if}

  <button type="submit">Login</button>
</form>

<style>
  .error {
    color: tomato;
  }
</style>

Let’s try using the update method and see if it works.

routes/+page.svelte
<script lang="ts">
  import { enhance, type SubmitFunction } from '$app/forms'
  import type { ActionData } from './$types'

  export let form: ActionData

  const login: SubmitFunction = () => {
    return async ({ update }) => {
      await update()
    }
  }
</script>

<form method="POST" action="/login" use:enhance={login}>
  <!-- ... -->
</form>

That won’t work because update can’t update form and $form.page from anywhere else but you can use applyAction and pass it result to customize the use:enhance behavior further.

routes/+page.svelte
<script lang="ts">
  import { applyAction, enhance, type SubmitFunction } from '$app/forms'
  import type { ActionData } from './$types'

  export let form: ActionData

  const login: SubmitFunction = () => {
    return async ({ result }) => {
      await applyAction(result)
    }
  }
</script>

<form method="POST" action="/login" use:enhance={login}>
  <!-- ... -->
</form>

If you submit the form form and $page.form is going to get updated.

As I mentioned previously result is an ActionResult object but I haven’t talk about its properties which are:

  • data returned from the action
  • status code
  • type which can be succes, failure, redirect, error

Using applyAction(result) is going to do different things based on result.type:

  • success and failure is going to update form, $page.form and $page.status regardless where you’re submitting from
  • redirect is going to invoke goto(result.location)
  • error is going to render the nearest +error.svelte page

Here’s an example of how that looks in action. 🥁

example.ts
const submit: SubmitFunction = ({ form, data, action, cancel }) => {
  // do something before the form submits

  return async ({ result }) => {
    // do something after the form submits
    
    if (result.type === 'success') {
      // do something...

      // use the default behavior for this result type
      await applyAction(result)
    }
    
    if (result.type === 'failure') { /* ... */ }

    if (result.type === 'redirect') { /* ... */ }
    
    if (result.type === 'error') { /* ... */ }
  }
}

This way you can customize the behavior of use:enhance and dip in and out of the regular behavior when you need it.

That’s everything you should know to feel confident working with forms in SvelteKit and I hope you learned how forms work but more importantly how to make your app more resilient.

If you want to continue learning in the next part I’m going to cover groups and how to reset your layout in SvelteKit.

Support

If you want to support the content you're reading or watching on YouTube consider becoming a patreon starting low as 1$ per month.

Become a patreon
Subscribe For Updates
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub