# 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 `Response` for 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). ```typescript 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. ```typescript 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`. ```typescript 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: ```typescript 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: ```typescript 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() }, }) ``` 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 params - `url` — the request URL - `request` — the raw `Request` Actions with action middleware: ```typescript import { createAction } from 'remix/router' import { requireAuth } from 'remix/middleware/auth' export const account = createAction(routes.account.index, { middleware: [requireAuth()], handler(context) { return render() }, }) ``` ## 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`: ```typescript async handler({ get }) { let db = get(databaseContext) let books = await db.findMany(books, { orderBy: ['id', 'asc'] }) return render() } ``` ### 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: ```typescript import { redirect } from 'remix/response/redirect' async create({ get }) { let formData = get(FormData) let parsed = s.parseSafe(bookSchema, formData) if (!parsed.success) { return render(, { 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. ```typescript 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() } ``` For form re-rendering with errors, return the page component with the parsed issues: ```typescript let formData = get(FormData) let parsed = s.parseSafe(signupSchema, formData) if (!parsed.success) { return render(, { 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: ```typescript 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. ```typescript 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() }, 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() }, }, }) ``` ### Root controller The root route map uses `app/actions/controller.tsx` and owns only top-level leaf routes: ```typescript // 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() }, }, }) ``` 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//controller.tsx`. Directory names under `app/actions/` are route-map keys, not URL path segments. ```typescript // app/actions/account/controller.tsx export default createController(routes.account, { middleware: [requireAuth()], actions: { index() { return render() }, }, }) // app/actions/account/settings/controller.tsx export default createController(routes.account.settings, { middleware: [requireAuth()], actions: { index() { return render() }, update() { return redirect(routes.account.index.href(), 303) }, }, }) ``` Then map each route map explicitly: ```typescript 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. ```typescript 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. ```typescript 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()`: ```typescript import { createRouter, type RouterContext } from 'remix/router' export const router = createRouter({ middleware: [formData(), session(cookie, storage), loadDatabase(), loadAuth()], }) export type AppContext = RouterContext declare module 'remix/router' { interface RouterTypes { context: AppContext } } ``` This gives typed `context.get(databaseContext)`, `context.get(Session)`, `context.get(Auth)`, etc.