Skip to main content
1

Add form data middleware

Enable automatic form parsing by adding the form data middleware:
app/router.ts
import { createRouter } from 'remix/fetch-router'
import { formData } from 'remix/form-data-middleware'

let middleware = [
  formData(),
]

export let router = createRouter({ middleware })
This middleware automatically parses application/x-www-form-urlencoded and multipart/form-data requests, making form data available via get(FormData).
2

Create a basic form

Build an HTML form that posts to your server:
app/pages/contact.tsx
import { css } from 'remix/component'
import { Document } from '../layout.tsx'

interface ContactFormProps {
  error?: string
  success?: boolean
}

export function ContactForm({ error, success }: ContactFormProps) {
  return (
    <Document title="Contact Us">
      <h1>Contact Us</h1>

      {error && (
        <div
          mix={[
            css({
              padding: '1rem',
              background: '#fee',
              border: '1px solid #fcc',
              borderRadius: '4px',
              marginBottom: '1rem',
            }),
          ]}
        >
          {error}
        </div>
      )}

      {success && (
        <div
          mix={[
            css({
              padding: '1rem',
              background: '#efe',
              border: '1px solid #cfc',
              borderRadius: '4px',
              marginBottom: '1rem',
            }),
          ]}
        >
          Thank you! Your message has been sent.
        </div>
      )}

      <form method="POST" action="/contact">
        <div mix={[css({ marginBottom: '1rem' })]}>
          <label
            for="name"
            mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
          >
            Name
          </label>
          <input
            type="text"
            id="name"
            name="name"
            required
            mix={[
              css({
                width: '100%',
                padding: '0.5rem',
                border: '1px solid #ddd',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>

        <div mix={[css({ marginBottom: '1rem' })]}>
          <label
            for="email"
            mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
          >
            Email
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            mix={[
              css({
                width: '100%',
                padding: '0.5rem',
                border: '1px solid #ddd',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>

        <div mix={[css({ marginBottom: '1rem' })]}>
          <label
            for="message"
            mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
          >
            Message
          </label>
          <textarea
            id="message"
            name="message"
            required
            rows={5}
            mix={[
              css({
                width: '100%',
                padding: '0.5rem',
                border: '1px solid #ddd',
                borderRadius: '4px',
              }),
            ]}
          />
        </div>

        <button
          type="submit"
          mix={[
            css({
              padding: '0.75rem 1.5rem',
              background: '#0070f3',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
              fontSize: '1rem',
            }),
          ]}
        >
          Send Message
        </button>
      </form>
    </Document>
  )
}
3

Handle form submissions

Create route handlers for both GET (show form) and POST (process form):
app/router.ts
import { createRouter } from 'remix/fetch-router'
import { formData } from 'remix/form-data-middleware'
import { routes } from 'remix/fetch-router/routes'
import { redirect } from 'remix/response/redirect'
import { render } from './utils/render.ts'
import { ContactForm } from './pages/contact.tsx'

export let appRoutes = routes({
  contact: {
    index: 'GET /contact',
    submit: 'POST /contact',
  },
})

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

// Show the form
router.get(appRoutes.contact.index, () => {
  return render(<ContactForm />)
})

// Process form submission
router.post(appRoutes.contact.submit, ({ get }) => {
  let form = get(FormData)
  
  let name = form.get('name')?.toString() ?? ''
  let email = form.get('email')?.toString() ?? ''
  let message = form.get('message')?.toString() ?? ''

  // Validate
  if (!name || !email || !message) {
    return render(
      <ContactForm error="All fields are required" />,
      { status: 400 }
    )
  }

  if (!email.includes('@')) {
    return render(
      <ContactForm error="Please enter a valid email address" />,
      { status: 400 }
    )
  }

  // Process the form (send email, save to database, etc.)
  console.log('Contact form submitted:', { name, email, message })

  // Show success message
  return render(<ContactForm success />)
})
4

Add field-level validation

Validate individual fields and show specific errors:
app/utils/validation.ts
interface ValidationResult {
  valid: boolean
  errors: Record<string, string>
}

export function validateContactForm(formData: FormData): ValidationResult {
  let errors: Record<string, string> = {}
  
  let name = formData.get('name')?.toString() ?? ''
  let email = formData.get('email')?.toString() ?? ''
  let message = formData.get('message')?.toString() ?? ''

  if (!name || name.trim().length < 2) {
    errors.name = 'Name must be at least 2 characters'
  }

  if (!email || !email.includes('@')) {
    errors.email = 'Please enter a valid email address'
  }

  if (!message || message.trim().length < 10) {
    errors.message = 'Message must be at least 10 characters'
  }

  return {
    valid: Object.keys(errors).length === 0,
    errors,
  }
}
Update your form component to show field-specific errors:
app/pages/contact.tsx
interface ContactFormProps {
  errors?: Record<string, string>
  values?: Record<string, string>
  success?: boolean
}

export function ContactForm({ errors = {}, values = {}, success }: ContactFormProps) {
  return (
    <Document title="Contact Us">
      <h1>Contact Us</h1>

      <form method="POST" action="/contact">
        <div mix={[css({ marginBottom: '1rem' })]}>
          <label for="name">Name</label>
          <input
            type="text"
            id="name"
            name="name"
            value={values.name}
            required
          />
          {errors.name && (
            <p mix={[css({ color: 'red', fontSize: '0.875rem', marginTop: '0.25rem' })]}>                  
              {errors.name}
            </p>
          )}
        </div>

        {/* Similar for email and message */}

        <button type="submit">Send Message</button>
      </form>
    </Document>
  )
}
Use validation in your handler:
import { validateContactForm } from './utils/validation.ts'

router.post(appRoutes.contact.submit, ({ get }) => {
  let form = get(FormData)
  let validation = validateContactForm(form)

  if (!validation.valid) {
    return render(
      <ContactForm
        errors={validation.errors}
        values={{
          name: form.get('name')?.toString() ?? '',
          email: form.get('email')?.toString() ?? '',
          message: form.get('message')?.toString() ?? '',
        }}
      />,
      { status: 400 }
    )
  }

  // Process form...
  return render(<ContactForm success />)
})
5

Use sessions for flash messages

Store temporary messages in sessions for the redirect-after-post pattern:
app/router.ts
import { session } from 'remix/session-middleware'
import { createCookieSessionStorage } from 'remix/session/cookie-storage'
import { redirect } from 'remix/response/redirect'

let sessionStorage = createCookieSessionStorage({
  cookie: {
    name: 'session',
    secrets: ['your-secret-key'],
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
})

export let router = createRouter({
  middleware: [
    formData(),
    session('session', sessionStorage),
  ],
})

// Show form with flash messages
router.get(appRoutes.contact.index, ({ get }) => {
  let session = get(Session)
  let success = session.get('success')
  let error = session.get('error')

  return render(<ContactForm success={success} error={error} />)
})

// Process and redirect
router.post(appRoutes.contact.submit, ({ get }) => {
  let form = get(FormData)
  let session = get(Session)

  // Validate and process...

  session.flash('success', true)
  return redirect('/contact')
})
6

Handle checkboxes and radio buttons

Work with different input types:
<form method="POST" action="/settings">
  {/* Checkbox */}
  <label>
    <input
      type="checkbox"
      name="newsletter"
      value="yes"
    />
    Subscribe to newsletter
  </label>

  {/* Multiple checkboxes */}
  <fieldset>
    <legend>Interests</legend>
    <label>
      <input type="checkbox" name="interests" value="tech" />
      Technology
    </label>
    <label>
      <input type="checkbox" name="interests" value="design" />
      Design
    </label>
  </fieldset>

  {/* Radio buttons */}
  <fieldset>
    <legend>Account Type</legend>
    <label>
      <input type="radio" name="accountType" value="personal" />
      Personal
    </label>
    <label>
      <input type="radio" name="accountType" value="business" />
      Business
    </label>
  </fieldset>

  <button type="submit">Save Settings</button>
</form>
Process in your handler:
router.post('/settings', ({ get }) => {
  let form = get(FormData)

  // Checkbox (single)
  let newsletter = form.get('newsletter') === 'yes'

  // Multiple checkboxes
  let interests = form.getAll('interests') // ['tech', 'design']

  // Radio button
  let accountType = form.get('accountType')?.toString() ?? 'personal'

  console.log({ newsletter, interests, accountType })

  return redirect('/settings?saved=true')
})
7

Add CSRF protection

Protect against cross-site request forgery:
app/middleware/csrf.ts
import type { Middleware } from 'remix/fetch-router'

function generateToken(): string {
  return Math.random().toString(36).substring(2)
}

export function csrf(): Middleware {
  return async ({ get, request }, next) => {
    let session = get(Session)

    // Generate token for GET requests
    if (request.method === 'GET') {
      if (!session.has('csrfToken')) {
        session.set('csrfToken', generateToken())
      }
      return next()
    }

    // Verify token for POST/PUT/PATCH/DELETE
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
      let form = get(FormData)
      let token = form.get('_csrf')?.toString()
      let sessionToken = session.get('csrfToken')

      if (!token || token !== sessionToken) {
        return new Response('Invalid CSRF token', { status: 403 })
      }
    }

    return next()
  }
}
Add token to forms:
router.get('/contact', ({ get }) => {
  let session = get(Session)
  let csrfToken = session.get('csrfToken')

  return render(<ContactForm csrfToken={csrfToken} />)
})

// In your form component
<form method="POST" action="/contact">
  <input type="hidden" name="_csrf" value={csrfToken} />
  {/* Other fields... */}
</form>

Form Best Practices

Always validate on the server

Client-side validation is good for UX, but server-side validation is essential for security.

Preserve form values on errors

When validation fails, repopulate the form with submitted values so users don’t have to retype everything.

Use proper HTTP methods

  • GET for displaying forms
  • POST for creating resources
  • PATCH/PUT for updating resources
  • DELETE for removing resources

Provide clear error messages

Tell users exactly what went wrong and how to fix it.

Build docs developers (and LLMs) love