Files
dotfiles/dot_agents/skills/remix/references/routing-and-controllers.md
T
Prad Nukala 42d17e825e chs: re-add drifted files
dot_agents/dot_skill-lock.json
2026-08-14 13:53:55 -04:00

363 lines
11 KiB
Markdown

# 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(<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 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(<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`:
```typescript
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:
```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(<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.
```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(<ShowPage book={book} />)
}
```
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(<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:
```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(<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:
```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(<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.
```typescript
// 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:
```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<typeof router>
declare module 'remix/router' {
interface RouterTypes {
context: AppContext
}
}
```
This gives typed `context.get(databaseContext)`, `context.get(Session)`, `context.get(Auth)`, etc.