Controllers
A controller is a .tsx file that exports a default { actions, middleware? } satisfies Controller<typeof routes.X, AppContext>. Controllers mirror the shape of the route map and are bound to it in app/router.ts with router.map(routes.X, controllerX).
Imports
import type { Controller } from 'remix/fetch-router'
import { redirect } from 'remix/response/redirect'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import { Database } from 'remix/data-table'
import { Session } from 'remix/session'
import { Auth } from 'remix/auth-middleware'
import type { AppContext } from '../router.ts'
import { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
Minimal controller
// app/controllers/home.tsx
import type { Controller } from 'remix/fetch-router'
import type { AppContext } from '../router.ts'
import { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
import { HomePage } from '../views/home-page.tsx'
export default {
actions: {
index() {
return render(<HomePage />)
},
},
} satisfies Controller<typeof routes.home, AppContext>
Action signature
Each action receives an object with:
| Field | Type / What it is |
|---|---|
get |
<K>(key: Key<K>) => K — pull values from context (e.g. get(Database)) |
params |
Typed params parsed from the route pattern |
request |
The raw Web Request |
url |
The parsed URL of the request |
Return value is a Response (usually built via render(<Page/>), redirect(...), or a manual new Response(...)).
Mirroring a route map
If a route map has nested entries, the controller mirrors that shape:
// app/controllers/admin/books/controller.tsx
import type { Controller } from 'remix/fetch-router'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import { Database } from 'remix/data-table'
import { redirect } from 'remix/response/redirect'
import type { AppContext } from '../../../router.ts'
import { routes } from '../../../routes.ts'
import { books } from '../../../data/schema.ts'
import { render } from '../../../utils/render.tsx'
import { AdminBooksIndexPage } from './views/index-page.tsx'
import { AdminBookShowPage } from './views/show-page.tsx'
import { AdminBookNewPage } from './views/new-page.tsx'
const bookSchema = f.object({
slug: f.field(s.string()),
title: f.field(s.string()),
author: f.field(s.string()),
description: f.field(s.string()),
price: f.field(s.string()),
genre: f.field(s.string()),
isbn: f.field(s.string()),
publishedYear: f.field(s.string()),
cover: f.field(s.string().nullable()),
inStock: f.field(s.boolean()),
})
export default {
actions: {
async index({ get }) {
const db = get(Database)
const allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<AdminBooksIndexPage books={allBooks} />)
},
new() {
return render(<AdminBookNewPage />)
},
async create({ get }) {
const db = get(Database)
const formData = get(FormData)
const parsed = s.parseSafe(bookSchema, formData)
if (!parsed.success) {
return render(<AdminBookNewPage errors={parsed.issues} />, { status: 400 })
}
const { price, publishedYear, cover, ...rest } = parsed.value
await db.create(books, {
...rest,
price: parseFloat(price),
cover_url: cover ?? '/images/placeholder.jpg',
image_urls: JSON.stringify([]),
published_year: parseInt(publishedYear, 10),
})
return redirect(routes.admin.books.index.href())
},
async show({ get, params }) {
const db = get(Database)
const book = await db.find(books, params.bookId)
if (!book) return new Response('Not found', { status: 404 })
return render(<AdminBookShowPage book={book} />)
},
},
} satisfies Controller<typeof routes.admin.books>
Controller-level middleware
A controller can declare middleware that runs before any of its actions (and any nested sub-controllers). Useful for guards like requireAuth or for hydrating per-request state.
import { requireAuth } from 'remix/auth-middleware'
export default {
middleware: [requireAuth()],
actions: {
index({ get }) { /* … */ },
settings: {
// nested controller — its own middleware can stack
middleware: [/* … */],
actions: { /* … */ },
},
},
} satisfies Controller<typeof routes.account>
Execution order: global router middleware → matched controller chain (parent → child) → action.
Reading typed context with get(Key)
Anything a middleware puts on the request context is fetched in actions via get(Key). Common keys:
const db = get(Database) // remix/data-table — provided by your db middleware
const session = get(Session) // remix/session — provided by session() middleware
const formData = get(FormData) // a parsed FormData — provided by formData() middleware
const auth = get(Auth) // current Auth state — provided by auth() middleware
If a key is missing from context the call throws. That's a wiring bug — fix the middleware stack in app/router.ts.
Common return shapes
// HTML page
return render(<Page />)
// HTML with a non-200 status (validation error, conflict, not-found)
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
// Redirect (after a successful POST, login, etc.)
return redirect(routes.admin.books.index.href())
// Bare response for 404s, JSON APIs, downloads, etc.
return new Response('Not found', { status: 404 })
return Response.json({ ok: true })
routes.X.href(params?) produces a typed URL. Always prefer it over hand-rolled paths — the compiler will tell you when you've broken a link.
Returning HTML without JSX
For small endpoints (webhooks, RSS, email previews), skip the JSX pipeline and use the html tagged template:
import { html } from 'remix/html-template'
import { createHtmlResponse } from 'remix/response/html'
return createHtmlResponse(html`<h1>Hello ${name}</h1>`)
See templating for the full story.
Further reading
references/controller-types.md—Controller<Routes, Context>, action shape, nested mirror rulesreferences/render-utility.md— buildingapp/utils/render.tsxaroundrenderToStreamreferences/context-keys.md— the standard set ofKeys exposed by built-in middleware- See also: templating (HTML responses without JSX), headers (typed parsers for Accept, CacheControl, etc.)