Skip to main content

What are Controllers?

Controllers organize related routes and their handlers into structured objects. They provide a clean way to group routes by feature or resource, with shared middleware and nested hierarchies.
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

let routes = route({
  home: '/',
  blog: {
    index: '/blog',
    show: '/blog/:slug',
  },
})

let router = createRouter()

router.map(routes, {
  actions: {
    home() {
      return new Response('Home')
    },
    blog: {
      actions: {
        index() {
          return new Response('Blog Index')
        },
        show({ params }) {
          return new Response(`Post: ${params.slug}`)
        },
      },
    },
  },
})

Controller Structure

A controller is an object with an actions property (and optionally a middleware property):
type Controller<routes extends RouteMap> = {
  actions: ControllerActions<routes>
  middleware?: Middleware[]
}
actions
ControllerActions
required
An object mapping route names to their handlers. The shape must match your route map structure.
middleware
Middleware[]
Middleware to run for all routes in this controller and its nested controllers.

Basic Controllers

The simplest controller maps routes to actions:
let routes = route({
  home: '/',
  about: '/about',
  contact: '/contact',
})

router.map(routes, {
  actions: {
    home() {
      return new Response('Home')
    },
    about() {
      return new Response('About')
    },
    contact() {
      return new Response('Contact')
    },
  },
})

Nested Controllers

Controllers can be nested to match your route structure:
let routes = route({
  home: '/',
  blog: {
    index: '/blog',
    show: '/blog/:slug',
    comments: {
      index: '/blog/:slug/comments',
      show: '/blog/:slug/comments/:id',
    },
  },
})

router.map(routes, {
  actions: {
    home() {
      return new Response('Home')
    },
    blog: {
      actions: {
        index() {
          return new Response('All Posts')
        },
        show({ params }) {
          return new Response(`Post: ${params.slug}`)
        },
        comments: {
          actions: {
            index({ params }) {
              return new Response(`Comments for: ${params.slug}`)
            },
            show({ params }) {
              return new Response(`Comment ${params.id} on ${params.slug}`)
            },
          },
        },
      },
    },
  },
})

Controller Middleware

Add middleware to run for all routes in a controller:
router.map(routes.admin, {
  // This middleware runs for ALL admin routes
  middleware: [requireAuth(), logger()],
  actions: {
    dashboard() {
      // requireAuth() and logger() run before this
      return new Response('Dashboard')
    },
    users() {
      // requireAuth() and logger() run before this too
      return new Response('Users')
    },
  },
})

Cascading Middleware

Middleware cascades through nested controllers:
router.map(routes.admin, {
  middleware: [requireAuth()], // Runs for all admin routes
  actions: {
    dashboard() {
      return new Response('Dashboard')
    },
    settings: {
      middleware: [requireSuperAdmin()], // Runs in addition to requireAuth()
      action() {
        // Both requireAuth() and requireSuperAdmin() have run
        return new Response('Settings')
      },
    },
  },
})
For the settings route, middleware runs in this order:
  1. Global middleware (from createRouter)
  2. requireAuth() (from parent controller)
  3. requireSuperAdmin() (from settings action)
  4. Settings action

Action Middleware

Attach middleware to individual actions:
router.map(routes, {
  actions: {
    home() {
      return new Response('Home')
    },
    admin: {
      // Middleware for a specific action
      middleware: [requireAuth()],
      action() {
        return new Response('Admin')
      },
    },
  },
})

Organizing Large Applications

Split Controllers into Modules

For large apps, split controllers into separate files:
// routes.ts
import { route, resources } from 'remix/fetch-router/routes'

export let routes = route({
  home: '/',
  posts: resources('posts'),
  users: resources('users'),
})
// controllers/posts.ts
import type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'

export let postsController: Controller<typeof routes.posts> = {
  actions: {
    index() {
      return Response.json([])
    },
    show({ params }) {
      return Response.json({ id: params.id })
    },
    // ... more actions
  },
}
// controllers/users.ts
import type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'

export let usersController: Controller<typeof routes.users> = {
  actions: {
    index() {
      return Response.json([])
    },
    show({ params }) {
      return Response.json({ id: params.id })
    },
    // ... more actions
  },
}
// router.ts
import { createRouter } from 'remix/fetch-router'
import { routes } from './routes.ts'
import { postsController } from './controllers/posts.ts'
import { usersController } from './controllers/users.ts'

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

router.map(routes.home, () => new Response('Home'))
router.map(routes.posts, postsController)
router.map(routes.users, usersController)

export { router }

Shared Controller Logic

Extract common patterns into reusable functions:
// lib/rest-controller.ts
import type { Controller } from 'remix/fetch-router'

export function createRestController<T>({
  findAll,
  findById,
  create,
  update,
  destroy,
}: {
  findAll: () => Promise<T[]>
  findById: (id: string) => Promise<T | null>
  create: (data: unknown) => Promise<T>
  update: (id: string, data: unknown) => Promise<T>
  destroy: (id: string) => Promise<void>
}): any {
  return {
    actions: {
      async index() {
        let items = await findAll()
        return Response.json(items)
      },
      async show({ params }) {
        let item = await findById(params.id)
        if (!item) {
          return new Response('Not Found', { status: 404 })
        }
        return Response.json(item)
      },
      async create({ request }) {
        let data = await request.json()
        let item = await create(data)
        return Response.json(item, { status: 201 })
      },
      async update({ params, request }) {
        let data = await request.json()
        let item = await update(params.id, data)
        return Response.json(item)
      },
      async destroy({ params }) {
        await destroy(params.id)
        return new Response(null, { status: 204 })
      },
    },
  }
}
// controllers/posts.ts
import { createRestController } from '../lib/rest-controller.ts'
import * as db from '../db.ts'

export let postsController = createRestController({
  findAll: () => db.posts.findAll(),
  findById: (id) => db.posts.findById(id),
  create: (data) => db.posts.create(data),
  update: (id, data) => db.posts.update(id, data),
  destroy: (id) => db.posts.delete(id),
})

Partial Controllers

You can map controllers to subsets of your routes:
let routes = route({
  home: '/',
  admin: {
    dashboard: '/admin',
    users: '/admin/users',
    posts: '/admin/posts',
  },
})

// Map just the admin routes
router.map(routes.admin, {
  middleware: [requireAuth()],
  actions: {
    dashboard() {
      return new Response('Dashboard')
    },
    users() {
      return new Response('Users')
    },
    posts() {
      return new Response('Posts')
    },
  },
})

// Map home separately
router.get(routes.home, () => new Response('Home'))

Type Safety

Controllers are fully type-safe. TypeScript ensures your controller structure matches your routes:
let routes = route({
  posts: {
    index: '/posts',
    show: '/posts/:id',
  },
})

router.map(routes.posts, {
  actions: {
    index() {
      return Response.json([])
    },
    show({ params }) {
      // params.id is typed as string
      console.log(params.id)
      return Response.json({ id: params.id })
    },
    // TypeScript error: 'invalid' doesn't exist in routes
    // invalid() { return new Response('Invalid') },
  },
})

Controller Patterns

Feature-Based Organization

Group routes by feature:
let routes = route({
  auth: {
    login: form('/login'),
    logout: post('/logout'),
  },
  blog: {
    index: '/blog',
    show: '/blog/:slug',
  },
  admin: {
    dashboard: '/admin',
  },
})

Resource-Based Organization

Use RESTful resources:
import { resources } from 'remix/fetch-router/routes'

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

Mixed Organization

Combine patterns as needed:
let routes = route({
  // Static pages
  home: '/',
  about: '/about',
  
  // Feature group
  auth: {
    login: form('/login'),
    logout: post('/logout'),
  },
  
  // RESTful resources
  posts: resources('posts'),
  
  // Admin section
  admin: {
    dashboard: '/admin',
    users: resources('admin/users'),
  },
})

Controller Best Practices

Keep Actions Focused

Each action should do one thing:
// Good: Focused actions
actions: {
  async index() {
    let posts = await db.posts.findAll()
    return Response.json(posts)
  },
  async show({ params }) {
    let post = await db.posts.findById(params.id)
    if (!post) {
      return new Response('Not Found', { status: 404 })
    }
    return Response.json(post)
  },
}

// Avoid: Actions doing too much
actions: {
  async page({ url }) {
    if (url.pathname.includes('/posts')) {
      // Handle posts
    } else if (url.pathname.includes('/users')) {
      // Handle users
    }
    // Too complex!
  },
}

Use Middleware for Cross-Cutting Concerns

Don’t repeat auth/validation logic in every action:
// Good: Middleware handles auth
router.map(routes.admin, {
  middleware: [requireAuth()],
  actions: {
    dashboard() {
      // Auth already checked
      return new Response('Dashboard')
    },
  },
})

// Avoid: Repeating auth in every action
actions: {
  dashboard({ headers }) {
    if (!headers.get('Authorization')) {
      return new Response('Unauthorized', { status: 401 })
    }
    return new Response('Dashboard')
  },
}

Extract Business Logic

Keep controllers thin, extract business logic to services:
// Good: Business logic in service
import * as postService from '../services/posts.ts'

actions: {
  async create({ request }) {
    let data = await request.json()
    let post = await postService.createPost(data)
    return Response.json(post, { status: 201 })
  },
}

// Avoid: Business logic in controller
actions: {
  async create({ request }) {
    let data = await request.json()
    
    // Validation
    if (!data.title || data.title.length < 3) {
      return new Response('Invalid title', { status: 400 })
    }
    
    // Transform
    let slug = data.title.toLowerCase().replace(/\s+/g, '-')
    
    // Save
    let post = await db.posts.insert({ ...data, slug })
    
    // Side effects
    await sendEmailNotification(post)
    
    return Response.json(post, { status: 201 })
  },
}

Next Steps

Forms

Learn about form routes and RESTful resources

Middleware

Compose reusable request processing logic

Build docs developers (and LLMs) love