Skip to content

Tutorial: Parse Form Submissions

In this tutorial you will parse text form submissions, handle file uploads, set size limits to protect your server, and write a custom upload handler to store files on disk.

Prerequisites

Step 1: Add the Form Data Middleware

ts
import { createRouter } from 'remix/fetch-router'
import { formData, FormData } from 'remix/form-data-middleware'
import { route } from 'remix/fetch-router/routes'

let routes = route({
  contactForm: '/contact',
  submitContact: '/contact',
})

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

Step 2: Build a Contact Form

Create a GET handler that renders the form:

ts
router.get(routes.contactForm, () => {
  return html`
    <h1>Contact Us</h1>
    <form method="post" action="/contact">
      <label for="name">Name</label>
      <input type="text" id="name" name="name" required />

      <label for="email">Email</label>
      <input type="email" id="email" name="email" required />

      <label for="message">Message</label>
      <textarea id="message" name="message" required></textarea>

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

Step 3: Handle the Submission

Use the FormData context key to access the parsed form data:

ts
router.post(routes.submitContact, async ({ context }) => {
  let data = context.get(FormData)

  let name = data.get('name')       // string | null
  let email = data.get('email')     // string | null
  let message = data.get('message') // string | null

  // Validate
  if (!name || !email || !message) {
    return new Response('All fields are required', { status: 400 })
  }

  await sendContactEmail({ name, email, message })
  return redirect('/contact/thanks')
})

The FormData import from 'remix/form-data-middleware' is a context key, not the global FormData constructor. Use context.get(FormData) to retrieve the parsed data.

Step 4: Handle File Uploads

To accept file uploads, add enctype="multipart/form-data" to your form:

ts
router.get(routes.uploadForm, () => {
  return html`
    <h1>Upload Avatar</h1>
    <form method="post" action="/profile/avatar" enctype="multipart/form-data">
      <label for="avatar">Choose an image</label>
      <input type="file" id="avatar" name="avatar" accept="image/*" />

      <button type="submit">Upload</button>
    </form>
  `
})

Access the uploaded file in your handler:

ts
router.post(routes.uploadAvatar, async ({ context }) => {
  let data = context.get(FormData)
  let avatar = data.get('avatar')

  if (avatar instanceof File) {
    console.log('Filename:', avatar.name)
    console.log('Size:', avatar.size, 'bytes')
    console.log('Type:', avatar.type)

    // Read the file contents
    let bytes = await avatar.arrayBuffer()
    // ... store it somewhere
  }

  return redirect('/profile')
})

Step 5: Set Size Limits

Protect your server from oversized uploads:

ts
formData({
  maxFileSize: 5 * 1024 * 1024,  // 5 MB per file
  maxFiles: 3,                     // Maximum 3 files per request
  maxParts: 50,                    // Maximum 50 total fields
})

If a file exceeds maxFileSize, the middleware returns a 413 Payload Too Large response before your handler runs.

Step 6: Write a Custom Upload Handler

The default handler stores files in memory, which is fine for small files. For larger files, write a custom handler that streams to disk:

ts
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { UploadHandler } from 'remix/form-data-middleware'

let diskUploadHandler: UploadHandler = async ({ filename, contentType, stream }) => {
  let safeName = `${crypto.randomUUID()}-${filename}`
  let path = join('./uploads', safeName)

  // Collect the stream into a buffer and write to disk
  let chunks: Uint8Array[] = []
  let reader = stream.getReader()
  while (true) {
    let { done, value } = await reader.read()
    if (done) break
    chunks.push(value)
  }

  let buffer = Buffer.concat(chunks)
  await writeFile(path, buffer)

  return new File([buffer], filename, { type: contentType })
}

formData({
  uploadHandler: diskUploadHandler,
  maxFileSize: 50 * 1024 * 1024, // 50 MB
})

The upload handler receives a stream (a ReadableStream of the file contents) along with the filename and contentType. It returns a File object that your handler can work with.

Step 7: Handle Multiple File Uploads

Use getAll() to retrieve multiple files from the same field:

html
<input type="file" name="photos" multiple />
ts
router.post(routes.uploadPhotos, async ({ context }) => {
  let data = context.get(FormData)
  let photos = data.getAll('photos') // Array of File objects

  for (let photo of photos) {
    if (photo instanceof File) {
      console.log(`Uploaded: ${photo.name} (${photo.size} bytes)`)
    }
  }

  return redirect('/gallery')
})

Step 8: Combine with Other Middleware

The formData middleware works well with CSRF protection and method override:

ts
let router = createRouter({
  middleware: [
    session(sessionCookie, sessionStorage),
    csrf(),
    formData(),
    methodOverride(),
  ],
})

The order matters:

  1. session() -- makes the session available for CSRF.
  2. csrf() -- validates the CSRF token from form data.
  3. formData() -- parses the form body.
  4. methodOverride() -- reads _method from parsed form data.

What You Learned

  • How to parse text form submissions using the FormData context key.
  • How to handle file uploads with enctype="multipart/form-data".
  • How to set size limits to protect against oversized uploads.
  • How to write a custom upload handler for disk storage.
  • How to handle multiple file uploads from a single field.

Next Steps

Released under the MIT License.