Skip to main content

Form Routes

The form() helper creates routes for displaying and handling HTML form submissions:
import { route, form } from 'remix/fetch-router/routes'

let routes = route({
  home: '/',
  contact: form('/contact'),
})

type Routes = typeof routes
// {
//   home: Route<'ANY', '/'>
//   contact: {
//     index: Route<'GET', '/contact'>   - Shows the form
//     action: Route<'POST', '/contact'>  - Handles form submission
//   }
// }
A form route map contains two routes:
  • index (GET) - Shows the form
  • action (POST) - Handles the form submission
pattern
string | RoutePattern
required
The URL pattern for the form
options
FormOptions
Configuration options for the form routes

Basic Form Example

import { createRouter } from 'remix/fetch-router'
import { route, form } from 'remix/fetch-router/routes'
import { formData } from 'remix/form-data-middleware'
import { html } from 'remix/html-template'
import { createHtmlResponse } from 'remix/response/html'

let routes = route({
  home: '/',
  contact: form('/contact'),
})

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

router.map(routes, {
  actions: {
    home() {
      return createHtmlResponse(html`
        <html>
          <body>
            <h1>Home</h1>
            <a href="${routes.contact.index.href()}">Contact Us</a>
          </body>
        </html>
      `)
    },
    contact: {
      actions: {
        // GET /contact - shows the form
        index() {
          return createHtmlResponse(html`
            <html>
              <body>
                <h1>Contact Us</h1>
                <form method="POST" action="${routes.contact.action.href()}">
                  <label for="name">Name</label>
                  <input type="text" name="name" required />
                  
                  <label for="email">Email</label>
                  <input type="email" name="email" required />
                  
                  <label for="message">Message</label>
                  <textarea name="message" required></textarea>
                  
                  <button type="submit">Send</button>
                </form>
              </body>
            </html>
          `)
        },
        // POST /contact - handles the form submission
        action({ get }) {
          let formData = get(FormData)
          let name = formData.get('name') as string
          let email = formData.get('email') as string
          let message = formData.get('message') as string
          
          // Process the form data (send email, save to database, etc.)
          console.log({ name, email, message })
          
          return createHtmlResponse(html`
            <html>
              <body>
                <h1>Thanks!</h1>
                <p>We received your message, ${name}. We'll get back to you at ${email}.</p>
                <a href="${routes.home.href()}">Back to Home</a>
              </body>
            </html>
          `)
        },
      },
    },
  },
})

Form Data Middleware

Use the formData() middleware to parse form submissions:
import { formData } from 'remix/form-data-middleware'

let router = createRouter({
  middleware: [formData()],
})
This middleware:
  • Parses application/x-www-form-urlencoded and multipart/form-data request bodies
  • Stores the parsed FormData in request context
  • Makes it available via context.get(FormData)

Accessing Form Data

Access form fields using the FormData API:
router.post(routes.contact.action, ({ get }) => {
  let formData = get(FormData)
  
  // Get a single value
  let name = formData.get('name') as string
  
  // Get all values for a field (e.g., checkboxes)
  let interests = formData.getAll('interests') as string[]
  
  // Check if a field exists
  if (formData.has('subscribe')) {
    console.log('User wants to subscribe')
  }
  
  // Iterate over all fields
  for (let [key, value] of formData.entries()) {
    console.log(`${key}: ${value}`)
  }
  
  return new Response('OK')
})

Custom Form Method

Change the form submission method:
let routes = route({
  profile: form('/profile', {
    formMethod: 'PUT',
  }),
})

type Routes = typeof routes
// {
//   profile: {
//     index: Route<'GET', '/profile'>
//     action: Route<'PUT', '/profile'>
//   }
// }
HTML forms only support GET and POST. To use PUT, PATCH, or DELETE, you’ll need to use JavaScript fetch() or the methodOverride middleware with a hidden input field.

Custom Route Names

Customize the route names:
let routes = route({
  login: form('/login', {
    names: {
      index: 'page',
      action: 'submit',
    },
  }),
})

type Routes = typeof routes
// {
//   login: {
//     page: Route<'GET', '/login'>
//     submit: Route<'POST', '/login'>
//   }
// }

router.map(routes.login, {
  actions: {
    page() { /* GET /login */ },
    submit({ get }) { /* POST /login */ },
  },
})

File Uploads

Handle file uploads with the formData() middleware:
let routes = route({
  upload: form('/upload'),
})

router.map(routes.upload, {
  actions: {
    index() {
      return createHtmlResponse(html`
        <html>
          <body>
            <h1>Upload File</h1>
            <form method="POST" action="${routes.upload.action.href()}" enctype="multipart/form-data">
              <label for="file">File</label>
              <input type="file" name="file" required />
              
              <label for="description">Description</label>
              <input type="text" name="description" />
              
              <button type="submit">Upload</button>
            </form>
          </body>
        </html>
      `)
    },
    async action({ get }) {
      let formData = get(FormData)
      let file = formData.get('file') as File
      let description = formData.get('description') as string
      
      console.log(`File: ${file.name}, Size: ${file.size}, Type: ${file.type}`)
      console.log(`Description: ${description}`)
      
      // Read file contents
      let contents = await file.text()
      // Or: let buffer = await file.arrayBuffer()
      
      return createHtmlResponse(html`
        <html>
          <body>
            <h1>Upload Complete</h1>
            <p>Uploaded: ${file.name} (${file.size} bytes)</p>
            <a href="${routes.upload.index.href()}">Upload Another</a>
          </body>
        </html>
      `)
    },
  },
})
Remember to set enctype="multipart/form-data" on forms that upload files.

Resource Routes

The resources() helper creates a full set of RESTful routes for a collection:
import { route, resources } from 'remix/fetch-router/routes'

let routes = route({
  posts: resources('posts'),
})

type Routes = typeof routes.posts
// {
//   index: Route<'GET', '/posts'>           - List all posts
//   new: Route<'GET', '/posts/new'>         - Show form to create a post
//   show: Route<'GET', '/posts/:id'>        - Show a single post
//   create: Route<'POST', '/posts'>         - Create a new post
//   edit: Route<'GET', '/posts/:id/edit'>   - Show form to edit a post
//   update: Route<'PUT', '/posts/:id'>      - Update a post
//   destroy: Route<'DELETE', '/posts/:id'>  - Delete a post
// }
pattern
string | RoutePattern
required
The base URL pattern for the resource
options
ResourcesOptions
Configuration options for the resource routes
Valid ResourcesMethod values: 'index', 'new', 'show', 'create', 'edit', 'update', 'destroy'

Full Resources Example

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

let routes = route({
  posts: resources('posts'),
})

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

router.map(routes.posts, {
  actions: {
    // GET /posts - List all posts
    index() {
      let posts = db.posts.findAll()
      return Response.json(posts)
    },
    
    // GET /posts/new - Show form to create a post
    new() {
      return createHtmlResponse(html`
        <form method="POST" action="${routes.posts.create.href()}">
          <input type="text" name="title" />
          <button type="submit">Create</button>
        </form>
      `)
    },
    
    // GET /posts/:id - Show a single post
    show({ params }) {
      let post = db.posts.findById(params.id)
      if (!post) {
        return new Response('Not Found', { status: 404 })
      }
      return Response.json(post)
    },
    
    // POST /posts - Create a new post
    create({ get }) {
      let formData = get(FormData)
      let post = db.posts.create({
        title: formData.get('title') as string,
      })
      return Response.json(post, { status: 201 })
    },
    
    // GET /posts/:id/edit - Show form to edit a post
    edit({ params }) {
      let post = db.posts.findById(params.id)
      if (!post) {
        return new Response('Not Found', { status: 404 })
      }
      return createHtmlResponse(html`
        <form method="POST" action="${routes.posts.update.href({ id: params.id })}">
          <input type="hidden" name="_method" value="PUT" />
          <input type="text" name="title" value="${post.title}" />
          <button type="submit">Update</button>
        </form>
      `)
    },
    
    // PUT /posts/:id - Update a post
    update({ params, get }) {
      let formData = get(FormData)
      let post = db.posts.update(params.id, {
        title: formData.get('title') as string,
      })
      return Response.json(post)
    },
    
    // DELETE /posts/:id - Delete a post
    destroy({ params }) {
      db.posts.delete(params.id)
      return new Response(null, { status: 204 })
    },
  },
})

Limiting Routes

Generate only the routes you need:
let routes = route({
  posts: resources('posts', {
    only: ['index', 'show', 'create'],
  }),
})

type Routes = typeof routes.posts
// {
//   index: Route<'GET', '/posts'>
//   show: Route<'GET', '/posts/:id'>
//   create: Route<'POST', '/posts'>
// }
Or exclude specific routes:
let routes = route({
  posts: resources('posts', {
    exclude: ['destroy', 'edit', 'update'],
  }),
})

type Routes = typeof routes.posts
// {
//   index: Route<'GET', '/posts'>
//   new: Route<'GET', '/posts/new'>
//   show: Route<'GET', '/posts/:id'>
//   create: Route<'POST', '/posts'>
// }

Custom Parameter Name

Change the parameter name from :id:
let routes = route({
  posts: resources('posts', {
    param: 'postId',
  }),
})

type Routes = typeof routes.posts
// {
//   ...
//   show: Route<'GET', '/posts/:postId'>
//   update: Route<'PUT', '/posts/:postId'>
//   destroy: Route<'DELETE', '/posts/:postId'>
// }

router.get(routes.posts.show, ({ params }) => {
  // params.postId instead of params.id
  return Response.json({ id: params.postId })
})

Custom Route Names

let routes = route({
  posts: resources('posts', {
    only: ['index', 'show'],
    names: {
      index: 'list',
      show: 'view',
    },
  }),
})

type Routes = typeof routes.posts
// {
//   list: Route<'GET', '/posts'>
//   view: Route<'GET', '/posts/:id'>
// }

Nested Resources

Create nested resource routes:
let routes = route({
  brands: {
    ...resources('brands', { only: ['index', 'show'] }),
    products: resources('brands/:brandId/products', {
      only: ['index', 'show', 'create'],
    }),
  },
})

type Routes = typeof routes.brands
// {
//   index: Route<'GET', '/brands'>
//   show: Route<'GET', '/brands/:id'>
//   products: {
//     index: Route<'GET', '/brands/:brandId/products'>
//     show: Route<'GET', '/brands/:brandId/products/:id'>
//     create: Route<'POST', '/brands/:brandId/products'>
//   }
// }

router.map(routes.brands, {
  actions: {
    index() {
      return Response.json(db.brands.findAll())
    },
    show({ params }) {
      return Response.json(db.brands.findById(params.id))
    },
    products: {
      actions: {
        index({ params }) {
          // params.brandId is available
          return Response.json(db.products.findByBrand(params.brandId))
        },
        show({ params }) {
          // Both params.brandId and params.id are available
          return Response.json(db.products.findById(params.id))
        },
        create({ params, get }) {
          let formData = get(FormData)
          let product = db.products.create({
            brandId: params.brandId,
            name: formData.get('name') as string,
          })
          return Response.json(product, { status: 201 })
        },
      },
    },
  },
})

Singleton Resources

For resources that don’t belong to a collection (like a user profile), use resource():
import { resource } from 'remix/fetch-router/routes'

let routes = route({
  profile: resource('profile'),
})

type Routes = typeof routes.profile
// {
//   new: Route<'GET', '/profile/new'>      - Show form to create profile
//   show: Route<'GET', '/profile'>         - Show the profile
//   create: Route<'POST', '/profile'>      - Create the profile
//   edit: Route<'GET', '/profile/edit'>    - Show form to edit profile
//   update: Route<'PUT', '/profile'>       - Update the profile
//   destroy: Route<'DELETE', '/profile'>   - Delete the profile
// }
Singleton resources don’t have an index route and don’t use :id parameters.
let routes = route({
  profile: resource('profile', {
    only: ['show', 'edit', 'update'],
  }),
})

router.map(routes.profile, {
  actions: {
    show({ get }) {
      let session = get(Session)
      let userId = session.get('userId')
      let profile = db.profiles.findByUserId(userId)
      return Response.json(profile)
    },
    edit({ get }) {
      let session = get(Session)
      let userId = session.get('userId')
      let profile = db.profiles.findByUserId(userId)
      return createHtmlResponse(html`
        <form method="POST" action="${routes.profile.update.href()}">
          <input type="text" name="name" value="${profile.name}" />
          <button type="submit">Update</button>
        </form>
      `)
    },
    update({ get }) {
      let session = get(Session)
      let userId = session.get('userId')
      let formData = get(FormData)
      let profile = db.profiles.update(userId, {
        name: formData.get('name') as string,
      })
      return Response.json(profile)
    },
  },
})

Next Steps

Middleware

Add form validation and parsing with middleware

Controllers

Organize resource routes with controllers

Build docs developers (and LLMs) love