Skip to content

Forms & Mutations

A mutation is any operation that changes data on the server --- creating, updating, or deleting a record. In Remix, mutations are handled through standard HTML forms, the same mechanism the web has used since its earliest days.

This guide covers every aspect of form handling in Remix. For a step-by-step introduction, see Part 5 of the tutorial.

HTML Forms and HTTP

When a user submits an HTML form, the browser collects all the input values and sends them as an HTTP request. The two key attributes on a <form> are:

  • method --- The HTTP method to use (GET or POST)
  • action --- The URL to send the data to (defaults to the current page)
html
<!-- GET form: appends data to the URL as query parameters -->
<form method="GET" action="/search">
  <input name="q" />
  <button type="submit">Search</button>
</form>
<!-- Submitting sends: GET /search?q=remix -->

<!-- POST form: sends data in the request body -->
<form method="POST" action="/books">
  <input name="title" />
  <button type="submit">Create</button>
</form>
<!-- Submitting sends: POST /books with body: title=My+Book -->

GET vs POST

GETPOST
PurposeRead/search dataWrite/change data
Data locationURL query stringRequest body
BookmarkableYesNo
CachedYesNo
IdempotentYes (safe to repeat)No (may create duplicates)

Rule of thumb: Use GET for searches and filters. Use POST (or PUT/DELETE) for anything that changes data.

The form() Route Helper

The form() helper creates a pair of routes at the same URL --- one for displaying the form (GET) and one for processing the submission (POST):

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

export const routes = route({
  contact: form('contact'),
})

This creates:

  • routes.contact.index --- GET /contact (show the form)
  • routes.contact.action --- POST /contact (handle the submission)

Mapping form() Routes to Handlers

ts
router.map(routes.contact, {
  actions: {
    index() {
      // Show the form
      return render(<ContactForm />)
    },
    action({ get }) {
      // Process the submission
      let data = get(FormData)
      // ...
    },
  },
})

Custom Form Methods

By default, form() uses POST for the action. Override it with formMethod:

ts
settings: form('settings', { formMethod: 'PUT' }),
// Creates:
//   GET /settings  (index)
//   PUT /settings  (action)

This is useful for update forms where PUT is semantically more appropriate.

Parsing Form Data with formData() Middleware

The formData() middleware parses the request body and makes the data available via the context:

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

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

In handlers, access the parsed data:

ts
handler({ get }) {
  let data = get(FormData)

  let name = data.get('name')       // string | File | null
  let email = data.get('email')     // string | File | null
  let tags = data.getAll('tags')    // (string | File)[]
}

The FormData object is the standard Web API FormData:

MethodDescription
data.get('key')Get a single value
data.getAll('key')Get all values (for checkboxes, multi-select)
data.has('key')Check if a key exists
data.entries()Iterate over all key-value pairs

Converting to a Plain Object

ts
let values = Object.fromEntries(data) as Record<string, string>
// { name: "Alice", email: "alice@example.com" }

Object.fromEntries drops duplicate keys

If your form has multiple fields with the same name (like checkboxes), Object.fromEntries only keeps the last value. Use data.getAll('key') for multi-value fields.

Data Validation with data-schema

Never trust data from the user. Always validate on the server.

Remix includes data-schema, a validation library that defines the expected shape of your data:

ts
import { object, string, number, parseSafe } from 'remix/data-schema'
import { email, minLength, min, max } from 'remix/data-schema/checks'
import { coerceNumber } from 'remix/data-schema/coerce'

Defining a Schema

ts
let ContactFormSchema = object({
  name: string().pipe(minLength(1)),
  email: string().pipe(email()),
  message: string().pipe(minLength(10)),
})
  • object({...}) --- Expects an object with the specified keys.
  • string() --- The value must be a string.
  • .pipe(minLength(1)) --- After confirming it is a string, check minimum length.
  • .pipe(email()) --- Check that the string looks like a valid email.

Validating Data

Use parseSafe for non-throwing validation:

ts
let result = parseSafe(ContactFormSchema, values)

if (!result.success) {
  // result.issues is an array of validation problems
  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 errors
  return render(<ContactForm errors={errors} values={values} />)
}

// result.value is the validated, typed data
let { name, email, message } = result.value

Coercing Types

HTML forms always send values as strings. For number fields, use coerceNumber:

ts
import { coerceNumber } from 'remix/data-schema/coerce'

let BookSchema = object({
  title: string().pipe(minLength(1)),
  price: coerceNumber(number().pipe(min(0))),
  year: coerceNumber(number().pipe(min(1000), max(2100))),
})

coerceNumber converts the string "19.99" to the number 19.99 before running the number() validations.

Available Checks

CheckDescription
minLength(n)String must be at least n characters
maxLength(n)String must be at most n characters
email()String must look like an email address
url()String must look like a URL
min(n)Number must be >= n
max(n)Number must be <= n
regex(pattern)String must match the regular expression

The Post/Redirect/Get Pattern

After a successful form submission, always redirect instead of returning a page directly:

ts
action({ get }) {
  let data = get(FormData)
  // ... validate and save data ...

  // Redirect to the list page
  return new Response(null, {
    status: 302,
    headers: { Location: routes.books.index.href() },
  })
}

Or use the helper:

ts
import { createRedirectResponse } from 'remix/response/redirect'

return createRedirectResponse(routes.books.index.href())

This pattern is called Post/Redirect/Get (PRG). It prevents two problems:

  1. Duplicate submissions --- If the user refreshes after a POST, the browser re-sends the form. With a redirect, refreshing just re-loads the GET page.
  2. Confusing browser behavior --- Without a redirect, the back button re-submits the form.

Flash messages with PRG

Use session flash messages to show a success notification after the redirect:

ts
let session = get(Session)
session.flash('message', 'Book created successfully!')
return createRedirectResponse(routes.books.index.href())

On the next page, read the flash:

ts
let message = session.get('message') // Only available once

See Sessions & Cookies for details.

Method Override for PUT and DELETE

HTML forms only support GET and POST. To use PUT and DELETE, Remix provides the methodOverride middleware:

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

let router = createRouter({
  middleware: [
    formData(),        // Must come first
    methodOverride(),  // Reads parsed form data
  ],
})

Add a hidden _method field to your form:

tsx
// Update form (PUT)
<form method="POST" action={routes.admin.books.update.href({ bookId: book.id })}>
  <input type="hidden" name="_method" value="PUT" />
  <input name="title" value={book.title} />
  <button type="submit">Update</button>
</form>

// Delete form (DELETE)
<form method="POST" action={routes.admin.books.destroy.href({ bookId: book.id })}>
  <input type="hidden" name="_method" value="DELETE" />
  <button type="submit">Delete</button>
</form>

The browser sends a POST request, but the methodOverride middleware reads the _method field and changes the request method before your handler sees it.

Middleware order matters

methodOverride() must come after formData() because it reads the parsed form data to find the _method field.

File Uploads with Multipart Forms

To upload files, add enctype="multipart/form-data" to the form and use <input type="file">:

tsx
<form method="POST" enctype="multipart/form-data">
  <input type="text" name="title" />
  <input type="file" name="cover" accept="image/*" />
  <button type="submit">Upload</button>
</form>

In the handler, file fields are File objects:

ts
action({ get }) {
  let data = get(FormData)

  let title = data.get('title') as string
  let coverFile = data.get('cover')

  if (coverFile instanceof File && coverFile.size > 0) {
    // Validate
    if (coverFile.size > 5 * 1024 * 1024) {
      return render(<Form errors={{ cover: 'File too large (max 5 MB)' }} />)
    }

    // Store the file
    let key = `covers/${crypto.randomUUID()}-${coverFile.name}`
    await fileStorage.set(key, coverFile)
  }
}

File Size Limits

Configure limits in the formData() middleware:

ts
formData({
  maxFileSize: 5 * 1024 * 1024,    // 5 MB per file
  maxTotalSize: 20 * 1024 * 1024,   // 20 MB total
  maxFiles: 5,                       // Maximum 5 files
})

File Type Validation

Always validate file types on the server:

ts
let allowedTypes = ['image/jpeg', 'image/png', 'image/webp']

if (!allowedTypes.includes(coverFile.type)) {
  return render(<Form errors={{ cover: 'Only JPEG, PNG, and WebP are allowed' }} />)
}

For more on file uploads, see Part 7 of the tutorial.

Displaying Validation Errors

A common pattern is to re-display the form with error messages and the user's previous input:

tsx
interface ContactFormProps {
  errors?: Record<string, string>
  values?: Record<string, string>
}

function ContactForm() {
  return ({ errors, values }: ContactFormProps) => (
    <form method="POST">
      <div class="form-group">
        <label for="name">Name</label>
        <input
          type="text"
          id="name"
          name="name"
          value={values?.name ?? ''}
        />
        {errors?.name ? <p class="error">{errors.name}</p> : null}
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          value={values?.email ?? ''}
        />
        {errors?.email ? <p class="error">{errors.email}</p> : null}
      </div>

      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" name="message" rows={5}>
          {values?.message ?? ''}
        </textarea>
        {errors?.message ? <p class="error">{errors.message}</p> : null}
      </div>

      <button type="submit">Send</button>
    </form>
  )
}

The handler for the form action:

ts
action({ get }) {
  let data = get(FormData)
  let values = Object.fromEntries(data) as Record<string, string>
  let result = parseSafe(ContactFormSchema, 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
      }
    }
    // Re-render the form with errors and previous values
    return render(<ContactForm errors={errors} values={values} />, {
      status: 422,
    })
  }

  // Validation passed --- process the data
  // ...

  return createRedirectResponse(routes.contact.index.href())
}

Status 422 for validation errors

Return status 422 (Unprocessable Entity) when validation fails. This makes it clear to clients and intermediaries that the request was understood but contained invalid data.

Progressive Enhancement

Everything described in this guide works without any client-side JavaScript. Forms submit via standard HTTP, the server processes the data, and the browser loads the response as a new page.

This is progressive enhancement --- a working baseline for everyone, with optional JavaScript enhancement on top.

What Works Without JavaScript

  • Form submissions (POST, with method override for PUT/DELETE)
  • Validation error display (server renders the form with errors)
  • Redirects (Post/Redirect/Get)
  • File uploads
  • Session-based flash messages

Client-Side Enhancements

With Remix's component system hydrated on the client, you can enhance forms with:

  • Instant validation --- Validate fields as the user types using on('input', ...) or on('blur', ...) mixins.
  • Submit without page reload --- Intercept the form submission and use fetch() to send data in the background.
  • Loading indicators --- Show a spinner while the form is being submitted.
  • Optimistic updates --- Update the UI immediately before the server responds.
tsx
import { on, css } from 'remix/component'

function EnhancedForm() {
  return (handle) => {
    let submitting = false

    return () => (
      <form
        method="POST"
        mix={on('submit', async (event) => {
          event.preventDefault()
          submitting = true
          handle.update()

          let form = event.target as HTMLFormElement
          let response = await fetch(form.action, {
            method: form.method,
            body: new FormData(form),
          })

          if (response.redirected) {
            window.location.href = response.url
          }

          submitting = false
          handle.update()
        })}
      >
        {/* ... form fields ... */}
        <button
          type="submit"
          disabled={submitting}
          mix={css({ opacity: submitting ? 0.5 : 1 })}
        >
          {submitting ? 'Sending...' : 'Send'}
        </button>
      </form>
    )
  }
}

The key point: the form works without JavaScript (standard POST submission), and JavaScript enhances it when available.

Complete Form Handler Example

Here is a complete example of a "create book" form handler with validation, error display, and redirect:

tsx
// app/controllers/admin/books.tsx
import type { Controller } from 'remix/fetch-router'
import { Database } from 'remix/data-table'
import { object, string, number, parseSafe } from 'remix/data-schema'
import { minLength, min } from 'remix/data-schema/checks'
import { coerceNumber } from 'remix/data-schema/coerce'
import { createRedirectResponse } from 'remix/response/redirect'
import { Session } from 'remix/session'

import { books } from '../../data/schema.ts'
import type { routes } from '../../routes.ts'
import { render } from '../../utils/render.tsx'

let NewBookSchema = object({
  title: string().pipe(minLength(1)),
  author: string().pipe(minLength(1)),
  price: coerceNumber(number().pipe(min(0))),
  genre: string().pipe(minLength(1)),
})

export default {
  actions: {
    // GET /admin/books/new --- show the form
    new() {
      return render(<NewBookForm />)
    },

    // POST /admin/books --- create the book
    async create({ get }) {
      let data = 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 render(
          <NewBookForm errors={errors} values={values} />,
          { status: 422 },
        )
      }

      let db = get(Database)
      await db.create(books, {
        ...result.value,
        slug: result.value.title.toLowerCase().replace(/\s+/g, '-'),
        in_stock: true,
      })

      let session = get(Session)
      session.flash('message', 'Book created!')

      return createRedirectResponse('/admin/books')
    },
  },
} satisfies Controller<typeof routes.admin.books>

Released under the MIT License.