11 KiB
Routing and Controllers
What This Covers
Patterns for declaring URLs, handling requests, and wiring routes to controllers. Read this when the task involves:
- Defining or changing the URL surface of the app
- Writing or reorganizing controllers and actions
- Reading request data (
params,url,request, context values) - Returning a
Responsefor HTML, redirects, JSON, or errors - Generating internal URLs with
.href()
The companion reference for shaping Request bodies, validating input, and dealing with persisted data is data-and-validation.md. For request lifecycle and middleware ordering, see middleware-and-server.md.
Route Builders
Import all route builders from remix/routes.
route(prefix, map) — nested route group
Adds a URL prefix to all children. Can also be called as route(map) without a prefix for a top-level grouping. Inside route(...), a nested map may be either a route('prefix', { ... }) call (when you want a shared URL prefix) or a plain object literal (when each leaf already owns its absolute path).
import { route, get, post } from 'remix/routes'
export const routes = route({
home: '/',
// Plain object — no shared prefix, each leaf has an absolute path.
books: {
index: '/books',
show: '/books/:slug',
},
// route('auth', ...) — every leaf is prefixed with /auth.
auth: route('auth', {
login: get('login'),
logout: post('logout'),
}),
})
Leaf route builders
| Builder | HTTP method | Example |
|---|---|---|
get(path) |
GET | get('/search') |
post(path) |
POST | post('/logout') |
put(path) |
PUT | put('/api/update') |
del(path) |
DELETE | del('/api/remove') |
| String literal | ANY | '/about' |
form(path, options?) — form route
Creates a GET + POST pair for HTML form workflows. Expands to an index (GET) and an action (POST) by default.
contact: form('contact')
// Produces routes.contact.index (GET /contact) and routes.contact.action (POST /contact)
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } })
// Produces routes.settings.index (GET) and routes.settings.update (PUT)
resources(name, options?) — REST resources
Expands to conventional CRUD routes: index, new, create, show, edit, update, destroy.
books: resources('books', { param: 'bookId' })
// GET /books, GET /books/new, POST /books, GET /books/:bookId, ...
orders: resources('orders', { only: ['index', 'show'], param: 'orderId' })
// GET /orders, GET /orders/:orderId
URL generation with .href()
Route objects expose .href() for type-safe URL generation:
redirect(routes.home.href())
redirect(routes.account.orders.show.href({ orderId: '42' }))
Actions
An action is the handler for one leaf route. In Remix app code, actions should live in controllers. Use Action only when a reusable helper needs to type one action before it is added to a controller or when you are doing low-level router wiring outside the app/actions convention:
import { createAction } from 'remix/router'
import { routes } from '../routes.ts'
export const search = createAction(routes.search, {
async handler({ url }) {
let query = url.searchParams.get('q') ?? ''
let results = await searchIndex(query)
return render(<SearchPage query={query} results={results} />)
},
})
The handler receives a context object with:
get(key)— read a value set by middleware (e.g.get(databaseContext),get(Session),get(Auth))params— typed route paramsurl— the request URLrequest— the rawRequest
Actions with action middleware:
import { createAction } from 'remix/router'
import { requireAuth } from 'remix/middleware/auth'
export const account = createAction(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
return render(<AccountPage />)
},
})
Returning Responses
An action returns a Response. The shape of that response is part of the route contract, and choosing it well saves a lot of glue elsewhere.
Render HTML
For pages, render a component tree and return the resulting Response:
async handler({ get }) {
let db = get(databaseContext)
let books = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<IndexPage books={books} />)
}
Redirect after a mutation
For state-changing routes (POST, PUT, PATCH, DELETE), the canonical reply is a redirect to the resulting page. Pass 303 explicitly when you want a POST-redirect-GET flow:
import { redirect } from 'remix/response/redirect'
async create({ get }) {
let formData = get(FormData)
let parsed = s.parseSafe(bookSchema, formData)
if (!parsed.success) {
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
}
let db = get(databaseContext)
let book = await db.create(books, parsed.value)
return redirect(routes.books.show.href({ slug: book.slug }), 303)
}
This pattern works without JavaScript and stays compatible with clientEntry(...) enhancements on top.
Return an error response
For expected failures — validation, conflict, not found — return a Response directly. Reserve thrown errors for genuinely unexpected failures.
async show({ get, params }) {
let db = get(databaseContext)
let book = await db.find(books, params.bookId)
if (!book) return new Response('Not Found', { status: 404 })
return render(<ShowPage book={book} />)
}
For form re-rendering with errors, return the page component with the parsed issues:
let formData = get(FormData)
let parsed = s.parseSafe(signupSchema, formData)
if (!parsed.success) {
return render(<SignupPage errors={parsed.issues} values={Object.fromEntries(formData)} />, {
status: 400,
})
}
Return JSON
For routes consumed by client code rather than rendered as a page (autocomplete endpoints, polling APIs, inter-service calls), return a JSON Response. Use SuperHeaders from remix/headers when typed header accessors make the response clearer:
import Headers from 'remix/headers'
let headers = new Headers()
headers.contentType = { mediaType: 'application/json', charset: 'utf-8' }
headers.cacheControl = { noStore: true }
return new Response(JSON.stringify({ results }), {
headers,
})
If you find yourself returning JSON for what is really a browser form submission, prefer the redirect-after-POST pattern instead. JSON-only mutation endpoints make it harder to support non-JS clients, harder to share rendering logic, and easier for the client to drift out of sync with the server.
Controllers
A controller owns the direct leaf routes in one route map. Each key in actions matches a direct leaf route key in the route definition passed to router.map(...). Nested route-map keys do not belong inside a controller's actions; map those route maps with their own controllers.
Configure RouterTypes.context with your app context in the router module, then use createController() so get(databaseContext), get(Session), get(Auth), etc. are typed against your middleware stack without repeating a type clause on every controller.
import { createController } from 'remix/router'
import { routes } from '../routes.ts'
export default createController(routes.books, {
actions: {
async index({ get }) {
let db = get(databaseContext)
let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<IndexPage items={items} />)
},
async show({ get, params }) {
let db = get(databaseContext)
let book = await db.find(books, params.bookId)
if (!book) return new Response('Not Found', { status: 404 })
return render(<ShowPage book={book} />)
},
},
})
Root controller
The root route map uses app/actions/controller.tsx and owns only top-level leaf routes:
// routes.ts
export const routes = route({
assets: get('/assets/*path'),
home: '/',
account: route('account', {
index: '/',
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } }),
}),
})
// app/actions/controller.tsx
export default createController(routes, {
actions: {
async assets({ request }) {
return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
},
home() {
return render(<HomePage />)
},
},
})
Because account is a nested route map, it is not an action key in the root controller.
Nested route maps
Nested route maps use their own controllers under app/actions/<route-key>/controller.tsx. Directory names under app/actions/ are route-map keys, not URL path segments.
// app/actions/account/controller.tsx
export default createController(routes.account, {
middleware: [requireAuth()],
actions: {
index() {
return render(<AccountPage />)
},
},
})
// app/actions/account/settings/controller.tsx
export default createController(routes.account.settings, {
middleware: [requireAuth()],
actions: {
index() {
return render(<SettingsPage />)
},
update() {
return redirect(routes.account.index.href(), 303)
},
},
})
Then map each route map explicitly:
import rootController from './actions/controller.tsx'
import accountController from './actions/account/controller.tsx'
import accountSettingsController from './actions/account/settings/controller.tsx'
let router = createRouter({ middleware })
router.map(routes, rootController)
router.map(routes.account, accountController)
router.map(routes.account.settings, accountSettingsController)
Controller middleware
The middleware array on a controller runs only for the direct actions in that controller, before action middleware. It does not apply to other controllers.
export default createController(routes.admin, {
middleware: [requireAuth(), requireAdmin()],
actions: {
/* all actions require auth + admin */
},
})
Registering Routes
Use router.map for route maps and controllers. Map each nested route map explicitly. Use verb methods only for low-level router wiring outside the app/actions controller convention.
let router = createRouter({ middleware })
// Route maps → controllers
router.map(routes, rootController)
router.map(routes.contact, contactController)
router.map(routes.auth, authController)
router.map(routes.auth.login, authLoginController)
router.map(routes.admin, adminController)
router.map(routes.admin.books, adminBooksController)
// Leaf route → one-off action
router.get(routes.search, searchAction)
router.post(routes.logout, logoutAction)
Typed Context
Define an AppContext type from your router, then make it the default context used by createAction() and createController():
import { createRouter, type RouterContext } from 'remix/router'
export const router = createRouter({
middleware: [formData(), session(cookie, storage), loadDatabase(), loadAuth()],
})
export type AppContext = RouterContext<typeof router>
declare module 'remix/router' {
interface RouterTypes {
context: AppContext
}
}
This gives typed context.get(databaseContext), context.get(Session), context.get(Auth), etc.