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 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:
Global middleware (from createRouter)
requireAuth() (from parent controller)
requireSuperAdmin() (from settings action)
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' )
},
}
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