mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-02 19:32:43 +00:00
363 lines
11 KiB
Markdown
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.
|