Skip to content

5. Forms & Mutations

So far, everything in our bookstore has been read-only -- we display pages and load data, but users cannot change anything. In this chapter, you will learn how to use HTML forms to accept user input, validate it on the server, and write data to the database.

How Forms Work on the Web

An HTML <form> is the standard way for a user to send data to a server. When someone fills out a form and clicks the submit button, the browser collects all the input values and sends them as an HTTP request.

Forms use two HTTP methods:

  • GET -- Appends the form data to the URL as query parameters. Used for searches and filters (reading data).
  • POST -- Sends the form data in the request body. Used for creating, updating, or deleting data (writing data).

What is a "mutation"?

A mutation is any operation that changes data on the server -- creating a new record, updating an existing one, or deleting one. The term comes from the idea that you are "mutating" (changing) the state of your application. Forms with method="post" are the primary way to trigger mutations on the web.

The Form Route Helper

When you build a page with a form, you typically need two handlers at the same URL:

  1. A GET handler that displays the form
  2. A POST handler that processes the form submission

Remix provides a form route helper that creates both routes at once. Let's start with a simple contact form to learn the pattern before building the bookstore admin.

Create a new file at app/routes/contact.ts:

ts
import { form } from 'remix/fetch-router/routes'

let contactRoutes = form('/contact')

This creates two routes at the /contact URL:

  • contactRoutes.index -- handles GET /contact (show the form)
  • contactRoutes.action -- handles POST /contact (process the submission)

Building a Contact Form

Let's wire up both handlers. First, update app/routes/contact.ts with the full implementation:

ts
import { form } from 'remix/fetch-router/routes'
import { html } from 'remix/html-template'

let contactRoutes = form('/contact')

export { contactRoutes }

export function showContactForm() {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Contact Us - The Book Nook</title>
      </head>
      <body>
        <h1>Contact Us</h1>
        <form method="post">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" required />
          </div>
          <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required />
          </div>
          <div>
            <label for="message">Message</label>
            <textarea id="message" name="message" rows="5" required></textarea>
          </div>
          <button type="submit">Send Message</button>
        </form>
      </body>
    </html>
  `
}

export function handleContactForm(name: string, email: string, message: string) {
  // In a real app, you would save to a database or send an email
  console.log(`Contact form: ${name} (${email}): ${message}`)
}

Notice the <form method="post"> in the HTML. Because we did not set an action attribute, the browser submits the form to the same URL (/contact). The method="post" tells the browser to send the data as a POST request, which will be handled by the action route.

Each <input> and <textarea> has a name attribute -- this is the key that identifies each piece of data when it arrives at the server.

Parsing Form Data with Middleware

When a browser submits a form, it encodes the data in a format called form-data (technically application/x-www-form-urlencoded). To read this data on the server, you need to parse it. Remix provides the formData middleware to do this automatically.

Update app/server.ts to add the form-data middleware:

ts
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'
import { createRouter } from 'remix/fetch-router'
import { logger } from 'remix/logger-middleware'
import { formData } from 'remix/form-data-middleware'
import { bookRoutes, showBookList, showBookDetail } from './routes/books.ts'
import {
  contactRoutes,
  showContactForm,
  handleContactForm,
} from './routes/contact.ts'

let router = createRouter({
  middleware: [logger(), formData()],
})

// Book routes (from previous chapters)
router.map(bookRoutes.list, showBookList)
router.map(bookRoutes.detail, showBookDetail)

// Contact form routes
router.map(contactRoutes.index, () => {
  return showContactForm()
})

router.map(contactRoutes.action, ({ context }) => {
  let data = context.get(FormData)
  let name = data.get('name') as string
  let email = data.get('email') as string
  let message = data.get('message') as string

  handleContactForm(name, email, message)

  return new Response('Thank you for your message!', {
    headers: { 'Content-Type': 'text/html' },
  })
})

let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000, () => console.log('http://localhost:3000'))

Here is what happens step by step:

  1. The formData() middleware runs on every non-GET request. It reads the request body and parses the form-encoded data.
  2. The parsed data is stored in the request context as a standard FormData object.
  3. In the POST handler, context.get(FormData) retrieves the parsed data.
  4. data.get('name') reads the value the user typed into the <input name="name"> field.

FormData is a Web API

FormData is a built-in browser API that Remix reuses on the server. It works like a Map -- you call .get('key') to retrieve values by their name attribute. This is the same API you would use in a browser to read form data from JavaScript.

Validating Form Data

Never trust data from the user. Someone could submit an empty form, type nonsense into the email field, or even send crafted requests that bypass your HTML validation. Server-side validation is essential.

Remix includes a validation library called data-schema that lets you define the shape and rules for your data, then check incoming data against those rules.

Update app/routes/contact.ts to add validation:

ts
import { form } from 'remix/fetch-router/routes'
import { html } from 'remix/html-template'
import { object, string, parseSafe } from 'remix/data-schema'
import { email, minLength } from 'remix/data-schema/checks'

let contactRoutes = form('/contact')

export { contactRoutes }

// Define the expected shape of the form data
let ContactFormSchema = object({
  name: string().pipe(minLength(1)),
  email: string().pipe(email()),
  message: string().pipe(minLength(10)),
})

export function showContactForm(errors?: Record<string, string>, values?: Record<string, string>) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Contact Us - The Book Nook</title>
      </head>
      <body>
        <h1>Contact Us</h1>
        <form method="post">
          <div>
            <label for="name">Name</label>
            <input
              type="text"
              id="name"
              name="name"
              value="${values?.name ?? ''}"
            />
            ${errors?.name ? `<p style="color: red">${errors.name}</p>` : ''}
          </div>
          <div>
            <label for="email">Email</label>
            <input
              type="email"
              id="email"
              name="email"
              value="${values?.email ?? ''}"
            />
            ${errors?.email ? `<p style="color: red">${errors.email}</p>` : ''}
          </div>
          <div>
            <label for="message">Message</label>
            <textarea id="message" name="message" rows="5">${values?.message ?? ''}</textarea>
            ${errors?.message ? `<p style="color: red">${errors.message}</p>` : ''}
          </div>
          <button type="submit">Send Message</button>
        </form>
      </body>
    </html>
  `
}

Let's break down the schema definition:

ts
let ContactFormSchema = object({
  name: string().pipe(minLength(1)),
  email: string().pipe(email()),
  message: string().pipe(minLength(10)),
})
  • object({...}) -- Expects an object with specific keys.
  • string() -- Each value must be a string.
  • .pipe(minLength(1)) -- After confirming it is a string, check that it has at least 1 character.
  • .pipe(email()) -- After confirming it is a string, check that it looks like a valid email address.

Handling Validation Errors

Now update the POST handler in app/server.ts to use validation:

ts
router.map(contactRoutes.action, ({ context }) => {
  let data = context.get(FormData)
  let values = Object.fromEntries(data) as Record<string, string>

  let result = parseSafe(ContactFormSchema, values)

  if (!result.success) {
    // Build a simple error map from the validation issues
    let errors: Record<string, string> = {}
    for (let issue of result.issues) {
      let key = issue.path?.[0]?.key
      if (typeof key === 'string') {
        errors[key] = issue.message
      }
    }

    // Re-display the form with the errors and the user's input
    return showContactForm(errors, values)
  }

  // Validation passed -- process the form
  handleContactForm(result.value.name, result.value.email, result.value.message)

  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Message Sent - The Book Nook</title>
      </head>
      <body>
        <h1>Thank You!</h1>
        <p>Your message has been sent. We will get back to you soon.</p>
        <a href="/contact">Send another message</a>
      </body>
    </html>
  `
})

The key function here is parseSafe:

  • If validation succeeds, result.success is true and result.value contains the validated data with proper types.
  • If validation fails, result.success is false and result.issues is an array of problems found.

When validation fails, we re-render the form with two additions:

  1. Error messages shown next to each field that has a problem.
  2. Previous values so the user does not have to re-type everything.

This pattern -- validate, show errors, preserve input -- is the foundation of good form handling on the web.

Always validate on the server

HTML attributes like required and type="email" provide helpful hints to the browser, but they are trivially bypassed. Someone can use browser developer tools or curl to send any data they want. Server-side validation is your real line of defense.

Building the Book Admin Form

Now let's apply what you have learned to the bookstore. We will build an admin page to add new books. Create app/routes/admin/books.ts:

ts
import { form } from 'remix/fetch-router/routes'
import { html } from 'remix/html-template'
import { object, string, number, parseSafe } from 'remix/data-schema'
import { minLength, min } from 'remix/data-schema/checks'
import { coerceNumber } from 'remix/data-schema/coerce'

let newBookRoutes = form('/admin/books/new')

export { newBookRoutes }

let NewBookSchema = object({
  title: string().pipe(minLength(1)),
  author: string().pipe(minLength(1)),
  year: coerceNumber(number().pipe(min(1000))),
  description: string().pipe(minLength(10)),
})

export function showNewBookForm(
  errors?: Record<string, string>,
  values?: Record<string, string>,
) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Add a Book - The Book Nook</title>
      </head>
      <body>
        <h1>Add a New Book</h1>
        <form method="post">
          <div>
            <label for="title">Title</label>
            <input
              type="text"
              id="title"
              name="title"
              value="${values?.title ?? ''}"
            />
            ${errors?.title ? `<p style="color: red">${errors.title}</p>` : ''}
          </div>
          <div>
            <label for="author">Author</label>
            <input
              type="text"
              id="author"
              name="author"
              value="${values?.author ?? ''}"
            />
            ${errors?.author ? `<p style="color: red">${errors.author}</p>` : ''}
          </div>
          <div>
            <label for="year">Year Published</label>
            <input
              type="number"
              id="year"
              name="year"
              value="${values?.year ?? ''}"
            />
            ${errors?.year ? `<p style="color: red">${errors.year}</p>` : ''}
          </div>
          <div>
            <label for="description">Description</label>
            <textarea id="description" name="description" rows="5">${values?.description ?? ''}</textarea>
            ${errors?.description ? `<p style="color: red">${errors.description}</p>` : ''}
          </div>
          <button type="submit">Add Book</button>
        </form>
      </body>
    </html>
  `
}

Coercing form values

HTML forms always send values as strings. When you have a number field like "year", the server receives the string "1984", not the number 1984. The coerceNumber helper from remix/data-schema/coerce automatically converts the string to a number before validating it.

Now add the route handlers in app/server.ts:

ts
import { newBookRoutes, showNewBookForm } from './routes/admin/books.ts'
import { insertInto } from 'remix/data-table'
import { db, BooksTable } from './db.ts'

// Show the "add book" form
router.map(newBookRoutes.index, () => {
  return showNewBookForm()
})

// Handle form submission
router.map(newBookRoutes.action, async ({ context }) => {
  let data = context.get(FormData)
  let values = Object.fromEntries(data) as Record<string, string>

  let result = parseSafe(NewBookSchema, values)

  if (!result.success) {
    let errors: Record<string, string> = {}
    for (let issue of result.issues) {
      let key = issue.path?.[0]?.key
      if (typeof key === 'string') {
        errors[key] = issue.message
      }
    }
    return showNewBookForm(errors, values)
  }

  // Insert the new book into the database
  await insertInto(db, BooksTable, {
    title: result.value.title,
    author: result.value.author,
    year: result.value.year,
    description: result.value.description,
  })

  // Redirect to the book list after success
  return new Response(null, {
    status: 302,
    headers: { Location: '/books' },
  })
})

After a successful insert, we return a redirect response (status 302) that sends the browser to the book list. This is a common pattern called Post/Redirect/Get -- it prevents the user from accidentally submitting the form twice if they refresh the page.

Editing Existing Books

For editing, you need the book's ID in the URL. Create app/routes/admin/books-edit.ts:

ts
import { form } from 'remix/fetch-router/routes'

let editBookRoutes = form('/admin/books/:id/edit')

export { editBookRoutes }

The GET handler loads the existing book and fills in the form:

ts
router.map(editBookRoutes.index, async ({ params }) => {
  let book = await selectFrom(db, BooksTable, {
    where: { id: Number(params.id) },
  })

  if (!book) {
    return new Response('Book not found', { status: 404 })
  }

  return showEditBookForm(book)
})

The POST handler validates and updates:

ts
router.map(editBookRoutes.action, async ({ params, context }) => {
  let data = context.get(FormData)
  let values = Object.fromEntries(data) as Record<string, string>

  let result = parseSafe(NewBookSchema, values)

  if (!result.success) {
    let errors: Record<string, string> = {}
    for (let issue of result.issues) {
      let key = issue.path?.[0]?.key
      if (typeof key === 'string') {
        errors[key] = issue.message
      }
    }
    return showEditBookForm({ ...values, id: params.id }, errors)
  }

  await update(db, BooksTable, {
    set: result.value,
    where: { id: Number(params.id) },
  })

  return new Response(null, {
    status: 302,
    headers: { Location: `/books/${params.id}` },
  })
})

Method Override for PUT and DELETE

HTML forms only support GET and POST methods. But HTTP has other methods like PUT (update) and DELETE (remove) that more precisely describe what you are doing. Remix provides the methodOverride middleware to bridge this gap.

Add it to your middleware stack:

ts
import { formData } from 'remix/form-data-middleware'
import { methodOverride } from 'remix/method-override-middleware'

let router = createRouter({
  middleware: [logger(), formData(), methodOverride()],
})

Middleware order matters

The methodOverride middleware must come after formData because it reads the parsed form data to find the override field. If you put it before formData, there will be no form data to read.

Now you can add a hidden field named _method to your forms:

html
<form method="post" action="/admin/books/42">
  <input type="hidden" name="_method" value="DELETE" />
  <button type="submit">Delete this book</button>
</form>

The browser sends this as a POST request (the only option for forms that change data), but the methodOverride middleware reads the _method field and changes the request method to DELETE before your handler runs. You can then use router.delete(...) to handle it:

ts
import { del } from 'remix/fetch-router/routes'

let deleteBookRoute = del('/admin/books/:id')

router.map(deleteBookRoute, async ({ params }) => {
  await deleteFrom(db, BooksTable, {
    where: { id: Number(params.id) },
  })

  return new Response(null, {
    status: 302,
    headers: { Location: '/books' },
  })
})

Progressive Enhancement

Everything we have built in this chapter works without any client-side JavaScript. When the user submits a form, the browser sends a full HTTP request, the server processes it, and the browser loads the response as a new page. This is how the web has worked since the beginning.

This approach is called progressive enhancement -- you start with a baseline that works for everyone, then optionally add JavaScript on top to enhance the experience (like showing a loading spinner or validating fields instantly as the user types).

Because Remix forms are standard HTML forms, they work even if:

  • JavaScript fails to load
  • The user has a slow connection
  • The user has disabled JavaScript
  • A search engine is crawling your site

In later chapters and guides, you will learn how to enhance forms with client-side JavaScript for a smoother experience. But the foundation is always a working HTML form.

Summary

In this chapter you learned:

  • HTML forms use GET for reading and POST for writing data
  • The form() route helper creates paired GET/POST routes at the same URL
  • The formData() middleware parses form submissions and makes them available via context.get(FormData)
  • data-schema validates form data on the server with clear error messages
  • parseSafe returns errors without throwing, making it easy to re-display the form
  • The Post/Redirect/Get pattern prevents duplicate submissions
  • methodOverride lets HTML forms use PUT and DELETE methods
  • Progressive enhancement means your forms work without JavaScript

Next, you will learn how to remember who a user is across requests with sessions and authentication.

Released under the MIT License.