
Remix
- 41 installs
- 33.3k repo stars
- Updated August 5, 2026
- remix-run/remix
Helps with ai & agent building tasks during AI-assisted development.
About
remix is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- remix
- AI & Agent Building
- AI-coding skill
Remix by the numbers
- 41 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #8,104 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/remix-run/remix --skill remixAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 41 |
|---|---|
| repo stars | ★ 33.3k |
| Last updated | August 5, 2026 |
| Repository | remix-run/remix ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Build a Remix App
Use this skill for end-to-end Remix app work. This skill helps you choose the right layer first, reach for the right package, and avoid the most common Remix-specific mistakes.
Full Package Documentation
This skill is the quick guide. When you need fuller API documentation, examples, or package-specific details for a remix/* subpath, first look for a README next to the relevant generated source file in the published remix package: node_modules/remix/src/<subpath>/README.md. These published README files are generated mirrors; in the Remix source repository, the canonical README lives in the owning packages/* package and the packages/remix/src/**/README.md mirrors are intentionally ignored. If that README does not exist, look for the nearest parent README because some subpaths share their parent package documentation.
Examples:
remix/router->node_modules/remix/src/fetch-router/README.mdremix/ui/button->node_modules/remix/src/ui/button/README.md
What Remix Is
Remix 3 is a server-first web framework built on Web APIs such as Request, Response, URL, and FormData. All packages ship from a single npm package, remix, and are imported via subpath. There is no top-level remix import.
A Remix app has four main pieces:
- Routes in
app/routes.tsdefine the typed URL contract and powerhref()generation. - Controllers in
app/actionsimplement that contract and returnResponseobjects. - Middleware composes request lifecycle behavior and populates typed context via
context.set(Key, value). - Components render UI with
remix/ui. This is not React. A component receives ahandle, reads current props fromhandle.props, and returns a zero-argument render function.
When To Use This Skill
Use this skill for:
- new features or refactors that touch routing, controllers, middleware, data, auth, sessions, UI, or tests
- reviewing Remix app code for correctness, architecture, or framework usage
- answering "how should this be structured in Remix?" questions
- finding the right package, reference doc, or default pattern for a task
Load Only The References You Need
Classify the task first, then load the smallest useful reference set. Each reference file starts with a "What This Covers" section that lists the topics inside it — read that first to confirm the file is relevant before reading the rest.
Use the table below to find candidates. Loading more than two or three files at once is usually a sign that the task hasn't been narrowed enough yet.
| Task involves... | Start with |
|---|---|
| Defining URLs, writing controllers and actions, returning responses | references/routing-and-controllers.md |
| Composing the request lifecycle, ordering middleware, bridging to a server | references/middleware-and-server.md |
| Compiling and serving browser modules, asset URL namespaces, preloads | references/assets-and-browser-modules.md |
| Parsing input, validating with schemas, defining tables, querying, migrations | references/data-and-validation.md |
| Per-browser state, login flows, route protection, identity | references/auth-and-sessions.md |
Component setup, state, lifecycle, updates, queueTask, context | references/component-model.md |
| Event handlers, styles, refs, click/key behavior, simple animations | references/mixins-styling-events.md |
clientEntry, run, <Frame>, navigation, <head> | references/hydration-frames-navigation.md |
| Router tests, component tests, test isolation | references/testing-patterns.md |
| Spring physics, tweens, layout transitions | references/animate-elements.md |
| Authoring custom reusable mixins | references/create-mixins.md |
Common bundles:
- Form or CRUD feature -> routing, data and validation, testing; add auth if user-specific
- Protected area -> auth and sessions, routing, testing
- Interactive widget -> component model, mixins and styling; add hydration only if it runs in the browser
- Browser asset pipeline -> assets and browser modules, hydration, middleware and server
- File upload -> middleware and server, data and validation, testing
- Navigation or frames -> hydration, frames, navigation
Default Workflow
1. Classify the change. Decide whether it changes the route contract, request lifecycle, data model, auth or session behavior, or only UI. 2. Start from the server contract. Add or update app/routes.ts before wiring handlers or UI. 3. Put code in the narrowest owner. Favor route-local code first, then promote only when reuse is real. 4. Make the server path correct before adding browser behavior. A route should return the right Response via router.fetch(...) before you add clientEntry(...), animations, or DOM effects. 5. Add middleware deliberately. Keep fast-exit middleware early and request-enriching middleware later. Export a typed AppContext from the middleware stack and use it in controllers. 6. Validate input at the boundary. Parse and validate Request, FormData, params, cookies, and external payloads before they reach rendering or persistence logic. 7. Hydrate only when necessary. Prefer server-rendered UI. Use clientEntry(...) and run(...) only for real browser interactivity or browser-only APIs. 8. Test the narrowest meaningful layer. Prefer router tests for route behavior. Use component tests when the behavior is truly interactive or DOM-specific. 9. Finish with verification. Re-read the route flow, confirm auth and authorization boundaries, and run the smallest relevant test and typecheck loop.
Project Layout
Use these root directories consistently:
app/for runtime application codedb/for migrations and local database filespublic/for static assets served as-istest/for shared helpers, fixtures, and integration coveragetmp/for uploads, caches, local session files, and other scratch data
Inside app/, organize by responsibility:
assets/for client entrypoints and client-owned browser behavioractions/for controller-owned route handlers, route-local response rendering, and route-local UI/helpers that are not shared across route areasdata/for schema, queries, persistence setup, migrations, and runtime data initializationmiddleware/for request lifecycle concerns such as auth, sessions, uploads, and database injectionui/for shared cross-route UI primitivesutils/only for genuinely cross-layer helpers that do not clearly belong elsewhereroutes.tsfor the route contractrouter.tsfor router setup and wiring
Placement Precedence
When code could live in multiple places:
1. Put it in the narrowest owner first. 2. If it belongs to one route, keep it with that route. 3. If it is shared UI across route areas, move it to app/ui/. 4. If it is request lifecycle setup, keep it in app/middleware/. 5. If it is schema, query, persistence, or startup data logic, keep it in app/data/. 6. Use app/utils/ only as a last resort for truly cross-layer helpers.
Route Ownership
- Put top-level leaf actions in
app/actions/controller.tsx - A controller's
actionsobject contains only direct leaf route keys from the route map passed torouter.map(...) - Add
app/actions/<route-key>/controller.tsxfor each nested route map that needs actions or controller middleware, and map it explicitly withrouter.map(routes.<routeKey>, controller) - Name directories under
app/actions/after route-map keys, not URL path segments - Keep route-local UI and helpers next to the controller that owns them
- Move shared cross-route UI to
app/ui/ - If a top-level leaf grows into a route map, move its handler into the nested route-key controller and update
app/router.tsto map that route map explicitly
Response Rendering And Utilities
- Treat response rendering as action-layer code: modules that return
Response, choose HTTP status or headers, callredirect(...), or call the localrender(...)helper belong inapp/actions - Keep
app/actions/render.tsxsmall; it should adaptremix/ui/serveroutput tocreateHtmlResponse(...). Route-specific response assembly can live in flat action modules, but directories underapp/actions/must still match route-map keys - Put pure support code in focused
app/utils/<topic>.tsmodules. Formatting, MIME classification, path parsing, sorting, and normalization should be testable without a router, request context, orResponse, and should not import fromapp/actions,remix/ui/server, orremix/response/* - Do not introduce page-data intermediary shapes only to keep route-specific renderers away from
render(...); keep response assembly in actions and extract only the pure helpers
Layout Anti-Patterns
- Do not create
app/lib/as a generic dumping ground - Do not create
app/components/as a second shared UI bucket whenapp/ui/already owns that role - Do not create
app/controllers/; Remix app route handlers live underapp/actions/ - Do not put shared cross-route UI in
app/actions/ - Do not create standalone root action files; put root route actions in
app/actions/controller.tsx - Do not put nested route-map keys in a controller's
actions - Do not register normal app leaf routes directly in
app/router.tswhen they belong in a controller - Do not rely on controller middleware from one controller to protect another controller; add controller middleware explicitly in each controller that needs it
- Do not put middleware or persistence helpers in
app/utils/when they have a clearer home
Core Remix Rules
- Import from
remix/<subpath>, neverimport { ... } from 'remix' - Treat
app/routes.tsas the source of truth for URLs. Useroutes.<name>.href(...)for redirects, links, tests, and internal URL construction - Controllers should return explicit
Responseobjects, including redirects, 404s, and validation failures. At the route boundary, prefer returning aResponsefor expected outcomes (validation errors, conflicts, not found) over throwing for control flow router.map(routes, controller)maps only the direct leaf routes inroutes; nested route maps must be mapped with their own explicit controllers- Model HTTP behavior explicitly. Status codes, headers, redirects, cache rules, and content types are part of the route contract
- Make the server route correct first. A POST should already return the right HTML, redirect, or error response on its own before
clientEntry(...)layers interactivity on top - Validate input at the boundary using
remix/data-schema(andremix/data-schema/form-datafor forms).parseSafemakes the failure path a return value instead of an exception - Derive
AppContextfrom the middleware stack soget(Database),get(Session),get(Auth), and similar keys stay typed. If the controller never reads from context, it doesn't need the harness - Outside actions and controllers, only use
getContext()whenasyncContext()is in the middleware stack - Remix Component is not React: write
function Name(handle: Handle<Props>) { return () => ... }, read props fromhandle.props, keep state in setup-scope variables, callhandle.update()explicitly, and do DOM-sensitive work in event handlers orqueueTask(...), not in render - Prefer host-element mixins via
mix={mixin(...)}for behavior and styling instead of inventing custom host prop conventions. Usemix={[...]}only when composing multiple mixins - Hydrated
clientEntry(...)props must be serializable. Do not pass functions, class instances, or opaque runtime objects
Security And Session Defaults
- Never ship demo secrets. In non-test environments, require session and provider secrets from the environment and fail fast if they are missing
- Use hardened cookies:
httpOnlyalways,sameSiteby default, andsecurewhen serving over HTTPS - Regenerate session IDs on login, logout, and privilege changes
- Use
requireAuth()to protect authenticated route areas, but still authorize resource ownership inside handlers and data writes - Add CSRF protection when browser forms mutate state using cookie-backed sessions
- Add CORS only for endpoints that must be called cross-origin. Prefer same-origin by default
- Prefer JSX or
remix/html-templatefor HTML generation so escaping stays correct - Validate uploads for size, type, and destination. Treat filenames and content as untrusted input
Testing Defaults
- Prefer server and router tests first. Drive the app with
router.fetch(new Request(...))and assert on the returnedResponse - Keep controller tests shaped like controllers: root route behavior belongs in
app/actions/controller.test.ts(x), and nested route-map behavior belongs beside that route-key controller - Build a fresh router per test or per suite so sessions, in-memory storage, and database state stay isolated
- Use
routes.<name>.href(...)in tests so URLs stay coupled to the route contract - For auth or session scenarios, use a test cookie and
createMemorySessionStorage()instead of production storage - Co-locate tests for pure
app/utilshelpers beside their modules. Test response behavior through router or controller tests - Use component tests only for interactive or DOM-specific behavior. Render with
createRoot(...), interact with the real DOM, and callroot.flush()between steps - Prefer one representative behavior test over many repetitive assertion variants
Common Mistakes To Avoid
- Treating Remix Component like React and reaching for hooks or implicit rerendering
- Importing from a top-level
remixentry instead of a subpath - Adding
clientEntry(...)before the server-rendered route behavior is correct - Passing non-serializable props into
clientEntry(...) - Calling
getContext()withoutasyncContext()in the middleware stack - Getting middleware order wrong; fast exits like static files belong early, request enrichment later
- Skipping boundary validation and trusting raw
FormData, params, cookies, or external payloads - Letting route-local domain errors leak out of the controller. Translate expected outcomes (validation, conflicts, not-found) into the HTTP
Responsethe route means to return rather than throwing a customErrorsubclass and catching it elsewhere - Reaching for
createCookiewhen a tamper-sensitive or server-managed per-browser fact really wantsremix/session. If editing the value would be a bug, use a session - Building a JSON-only RPC layer when a normal form POST, redirect, or resource route would be simpler. Fetch-from-the-client is a layer on top of sound route behavior, not a replacement for it
- Treating JSON state endpoints and
<Frame>reloads as mutually exclusive patterns. Pick the lightest sync mechanism that fits the UX; small widgets may reasonably poll a JSON endpoint - Assuming authentication is enough without per-resource authorization checks
- Dropping shared code into vague buckets like
utils.ts,helpers.ts, orcommon.tswhen ownership is known - Recreating the old
app/controllersor standalone root action file layout instead of using controllers underapp/actions - Putting nested route-map keys inside a controller
actionsobject. Map nested route maps explicitly inapp/router.ts - Treating direct
router.get(...)/router.post(...)registrations as the default app structure instead of using controllers - Assuming controller middleware applies to controllers registered for nested route maps
- Writing only component tests for a feature whose main behavior is really an HTTP route concern
Package Map
Use this map to find the right package quickly. Each entry says what the package is for, not just what it exports. Open the linked reference file when you need full examples.
Routing, Server, and Responses
remix/router— the router itself. Use forcreateRouter, controllers, middleware types, and registering routesremix/routes— declarative route builders. Use forroute,get,post,put,del,form,resourceswhen definingapp/routes.tsremix/node-fetch-server— default Node adapter for new apps. UsecreateRequestListenerwithnode:http,node:https, ornode:http2inserver.tswhen booting the template-style appremix/assets— browser asset server. Use forcreateAssetServerwhen serving compiled scripts and styles, getting public hrefs, and emitting preloads. Configure abasePath, and keepfileMapURL patterns relative to it. Shared compiler options such astarget,sourceMaps,sourceMapSourcePaths, andminifylive at the top levelremix/headers—SuperHeadersplus typed header parsers and builders. Use the default export when you want aHeaderssubclass with typed accessors likeheaders.contentType,headers.cacheControl, andheaders.setCookie; use named classes such asCacheControl,ContentDisposition, andVarywhen working with individual header valuesremix/response/redirect—redirect(href, status?). Use for the canonical "POST then redirect" pattern and other location changesremix/response/html—createHtmlResponse. Use when you need an HTMLResponsefrom a string or stream without rendering throughremix/uiremix/response/compress—compressResponse. Use when compressing one-off responses outsidecompression()middlewareremix/response/file— file-download responses. Use forContent-Disposition: attachmentresponsesremix/route-pattern— low-level URL matching and generation. UseRoutePatternorcreateMatcherwhen working with raw patterns outside the router.href(...)encodes pathname and search params for you, andmatch(...)returns decoded paramsremix/route-pattern/specificity— pattern ranking helpers. Use only when building custom matcher or reporting logic outside the normal router/matcher APIsremix/fetch-proxy— Fetch-based HTTP proxying. Use to forward a request to another origin; passxForwardedHeaderswhen the upstream needs forwarded proto, host, and port. It also rewrites proxiedSet-Cookiedomain/path attributes by default
Data, Validation, and Persistence
remix/data-schema— schema builders for runtime validation. Use forparseandparseSafeto validate any input that crosses a trust boundary, and.transform(...)when validated output should map to a different value or typeremix/data-schema/checks— common check helpers (email,minLength,maxLength, etc.). Use to compose into a schemaremix/data-schema/coerce— coercion helpers for strings, numbers, booleans, dates, and ids. Use when input arrives as a string but should be a typed valueremix/data-schema/form-data—f.objectandf.fieldfor parsingFormDatadirectly. Use in actions that read browser formsremix/data-schema/lazy— recursive or mutually-referential schemas. Use when a schema needs to refer to itself or another schema that is declared laterremix/data-table— typed tables and aDatabaseinterface. Use fortable,column,createDatabasewhen modeling persisted dataremix/data-table/sqlite,remix/data-table/postgres,remix/data-table/mysql— adapters. Use to backcreateDatabasewith a real engine. SQLite accepts Node, Bun, and compatible synchronous clients with the sharedprepare/execsurfaceremix/data-table/migrations— migration authoring and runners. Use forcreateMigration,createMigrationRunnerremix/data-table/migrations/node—loadMigrationsfrom disk. Use in startup scripts that apply migrationsremix/data-table/operators— query operators such asinList(...). Use whenwhereclauses need set or comparison logicremix/data-table/sql-helpers— SQL helper utilities for adapter or advanced query work. Avoid this in normal app code unless you are intentionally working below the table/query API
Auth, Sessions, and Cookies
remix/session— theSessionobject:get,set,flash,unset,regenerateId. Use for any per-browser state where tampering would be a bug (login, "I submitted this form already", cart, flash messages)remix/middleware/session—session(cookie, storage). Use to wire a session cookie and storage backend into the middleware stackremix/session-storage/fs,remix/session-storage/memory,remix/session-storage/cookie— storage backends. Usefs-storagefor single-process apps,memory-storagefor tests,cookie-storagefor stateless deployments where data fits in a cookieremix/session-storage/redis— Redis-backed storage. Use for multi-process or multi-host deploymentsremix/session-storage/memcache— Memcache-backed storage. Same multi-host use case as Redisremix/cookie—createCookiefor plain signed/unsigned cookies. Use for non-sensitive preferences where the client is allowed to control the value (theme, locale, dismissed banner). For state where tampering matters, preferremix/sessionremix/auth— credentials, OAuth, OIDC, and Atmosphere providers. Use to define how identity is verified, start/finish external login, and refresh stored OAuth/OIDC token bundles withrefreshExternalAuth(...)remix/middleware/auth—auth({ schemes }),requireAuth, theAuthcontext key. Use to resolve identity into the request context and to gate routes
UI, Hydration, and Browser Behavior
remix/ui— the component runtime: components, core mixins,clientEntry,run,<Frame>, navigation helpers, andcreateRoot. Use for app UI behaviorremix/ui/server— server rendering:renderToStream,renderToString. Use in theapp/actions/render.tsxhelper that returns HTML responsesremix/ui/animation— animation APIs:animateEntrance,animateExit,animateLayout,spring,tween, andeasingsremix/ui/<primitive>— UI primitives, mixins, glyphs, and theme helpers. Current subpaths includeremix/ui/accordion,remix/ui/anchor,remix/ui/breadcrumbs,remix/ui/button,remix/ui/combobox,remix/ui/glyph,remix/ui/listbox,remix/ui/menu,remix/ui/popover,remix/ui/scroll-lock,remix/ui/select,remix/ui/separator, andremix/ui/themeremix/ui/test— component test rendering helpers such asrenderremix/ui/jsx-runtimeandremix/ui/jsx-dev-runtime— JSX transform targets. Configured intsconfig.json, rarely imported directlyremix/html-template— escaped HTML template literals. Use when generating HTML outside the component system (RSS feeds, email bodies, error pages)remix/file-storage— backend-agnosticFilestorage interface. Use as the type bound for upload destinationsremix/file-storage/fs,remix/file-storage/memory,remix/file-storage/s3— storage backends. Use to implement an upload destination
Middleware
remix/middleware/static—staticFiles(dir). Use to serve files frompublic/exactly as they exist on diskremix/middleware/form-data—formData(). Use to parseFormDataonce and expose it viaget(FormData)instead of callingawait request.formData()in each actionremix/form-data-parser— lower-levelparseFormData,FileUpload. Use when implementing custom upload handlers. Upload handler errors propagate directlyremix/multipart-parserandremix/multipart-parser/node— low-level multipart stream parsing.MultipartPart.headersis a plain object keyed by lower-case header name; read values with bracket notation such aspart.headers['content-type']remix/middleware/compression—compression(). Use for text-like responsesremix/middleware/logger—logger(). Use in development for request logs; passcolorsto force terminal color output on or offremix/middleware/method-override—methodOverride(). Use when HTML forms needPUT,PATCH, orDELETEremix/middleware/async-context—asyncContext(),getContext(). Use when helpers outside actions need request context without threading it through every callremix/middleware/cors—cors(opts?). Use for endpoints called cross-originremix/middleware/csrf—csrf(opts?). Use when session-backed forms mutate state and need synchronizer-token CSRF protectionremix/middleware/cop— cross-origin protection. Use to reject unsafe cross-origin browser requests
Test
remix/test—describe,it, and lifecycle hooks. Use as the test frameworkremix/test/cli— programmatic test runner APIs such asrunRemixTestremix/node-fetch-server/test—createTestServerfor end-to-end tests that need a real local HTTP server around a Fetch handlerremix/cli— programmatic Remix CLI API. Use theremixexecutable for project commands such asremix test,remix routes,remix doctor, andremix versionremix/assert— assertion helpers. Use in place ofnode:assertso messages render cleanly in the runnerremix/terminal— ANSI styles, color detection, style factories, and testable terminal streams. Use for CLIs and terminal output instead of hand-rolled escape sequencesremix/fs— small filesystem helpers such asopenLazyFileandwriteFile. Use in Node-only app or tooling code when you need lazy file responses or safe file writesremix/lazy-file—LazyFileprimitives and byte-range helpers. Use when implementing file or range responses below the higher-level response/file helpersremix/mime— content-type and MIME detection helpers. Use instead of maintaining app-local extension mapsremix/tar-parser— streaming tar parsing. Use for import/export tooling that consumes tar archives
Canonical Patterns
Define routes first
import { form, get, post, resources, route } from 'remix/routes'
export const routes = route({
home: '/',
contact: form('contact'),
books: {
index: '/books',
show: '/books/:slug',
},
auth: route('auth', {
login: form('login'),
logout: post('logout'),
}),
admin: route('admin', {
index: get('/'),
books: resources('books', { param: 'bookId' }),
}),
})Type controllers against the route contract
import { createController } from 'remix/router'
import { routes } from '../routes.ts'
export default createController(routes.books, {
actions: {
async index({ get }) {
let db = get(Database)
let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<BooksIndexPage allBooks={allBooks} />)
},
async show({ get, params }) {
let db = get(Database)
let book = await db.findOne(books, { where: { slug: params.slug } })
if (!book) return new Response('Not Found', { status: 404 })
return render(<BookShowPage book={book} />)
},
},
})Register Controllers Explicitly
import { createRouter } from 'remix/router'
import rootController from './actions/controller.tsx'
import adminController from './actions/admin/controller.tsx'
import adminBooksController from './actions/admin/books/controller.tsx'
import authController from './actions/auth/controller.tsx'
import authLoginController from './actions/auth/login/controller.tsx'
import booksController from './actions/books/controller.tsx'
import contactController from './actions/contact/controller.tsx'
import { routes } from './routes.ts'
export const router = createRouter({ middleware })
router.map(routes, rootController)
router.map(routes.contact, contactController)
router.map(routes.books, booksController)
router.map(routes.auth, authController)
router.map(routes.auth.login, authLoginController)
router.map(routes.admin, adminController)
router.map(routes.admin.books, adminBooksController)Compose middleware deliberately
import { createRouter } from 'remix/router'
let middleware = []
if (process.env.NODE_ENV === 'development') {
middleware.push(logger())
}
middleware.push(compression())
middleware.push(staticFiles('./public'))
middleware.push(formData())
middleware.push(methodOverride())
middleware.push(session(cookie, storage))
middleware.push(asyncContext())
middleware.push(loadDatabase())
middleware.push(loadAuth())
let router = createRouter({ middleware })Validate, mutate, and respond
import { createController } from 'remix/router'
import { redirect } from 'remix/response/redirect'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import { Session } from 'remix/session'
import { Database } from 'remix/data-table'
import { routes } from '../routes.ts'
let bookSchema = f.object({
slug: f.field(s.string()),
title: f.field(s.string()),
})
export default createController(routes.books, {
actions: {
async create({ get }) {
let parsed = s.parseSafe(bookSchema, get(FormData))
if (!parsed.success) {
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
}
let db = get(Database)
let book = await db.create(books, parsed.value)
let session = get(Session)
session.flash('message', `Added ${book.title}.`)
return redirect(routes.books.show.href({ slug: book.slug }))
},
},
})This shape works without JavaScript, returns a Response for every outcome, and is ready for clientEntry(...) interactivity when the UI needs it.
Build UI from handle props plus render
import { on, type Handle } from 'remix/ui'
function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
mix={on('click', () => {
count++
handle.update()
})}
>
{handle.props.label}: {count}
</button>
)
}Only add clientEntry(...) and run(...) when the component needs browser interactivity or browser-only APIs.
Animating Elements
What This Covers
How to animate insertion, removal, and layout changes of elements. Read this when the task involves:
- Adding entrance, exit, or shared-layout transitions to UI
- Choosing between spring physics (
spring(...)) and time-based easing (tween) - Coordinating CSS transitions with the same easing as JS animations
- Imperative animation loops via
requestAnimationFrame
Import animation APIs from remix/ui/animation. For the smaller set of animation helpers that show up alongside other mixins, see mixins-styling-events.md.
Animation Mixins
animateEntrance(config)
Animates an element when inserted. Config specifies the starting style the element animates from:
<div
mix={animateEntrance({
opacity: 0,
transform: 'translateY(8px)',
...spring('smooth'),
})}
/>animateExit(config)
Animates an element when removed. Config specifies the ending style the element animates to. The element stays in the DOM until the animation completes:
{
isVisible && (
<div
key="panel"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
]}
/>
)
}animateLayout(config?)
Animates layout changes (position/size) using FLIP-style transforms:
{
items.map((item) => (
<li key={item.id} mix={animateLayout({ ...spring({ duration: 500, bounce: 0.2 }) })} />
))
}Options: duration (default 200ms), easing (default spring snappy), size (default true — include scale projection for size changes).
Combining mixins
<div
key="card"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.95)', ...spring('snappy') }),
animateExit({ opacity: 0, transform: 'scale(0.98)', duration: 120, easing: 'ease-in' }),
animateLayout({ duration: 220, easing: 'ease-out' }),
]}
/>Shared-layout swap
<div mix={css({ display: 'grid', '& > *': { gridArea: '1 / 1' } })}>
{stateA ? (
<div key="a" mix={[animateEntrance({ opacity: 0 }), animateExit({ opacity: 0 })]} />
) : (
<div key="b" mix={[animateEntrance({ opacity: 0 }), animateExit({ opacity: 0 })]} />
)}
</div>Spring API
Physics-based spring animation. Returns a SpringIterator with duration, easing, and toString() for CSS.
Presets
| Preset | Bounce | Duration | Character |
|---|---|---|---|
smooth | -0.3 | 400ms | Overdamped, no overshoot |
snappy | 0 | 200ms | Critically damped, quick |
bouncy | 0.3 | 400ms | Underdamped, visible bounce |
spring('bouncy')
spring('snappy')
spring('smooth')
spring('bouncy', { duration: 300 }) // override durationCustom spring
spring({ duration: 500, bounce: 0.3 })
spring({ duration: 500, bounce: 0.3, velocity: 2 }) // continue momentum from gestureSpread into animation mixins
Spreading a spring gives both duration and easing:
animateEntrance({ opacity: 0, ...spring('bouncy') })CSS transitions
The iterator stringifies to "550ms linear(...)":
css({ transition: `width ${spring('bouncy')}` })Or use the spring.transition() helper for multiple properties:
css({ transition: spring.transition('width', 'bouncy') })
css({ transition: spring.transition(['left', 'top'], 'snappy') })Web Animations API
element.animate(keyframes, { ...spring('bouncy') })JS iteration
The iterator yields position values from 0 to 1, one per frame:
for (let t of spring('bouncy')) {
let x = from + (to - from) * t
updateSomething(x)
await nextFrame()
}Tween API
Generator-based tween for animating values over time with cubic bezier easing. Prefer animation mixins or CSS transitions with spring for most UI work. Use tween for imperative requestAnimationFrame loops, canvas/WebGL, or non-CSS properties.
import { tween, easings } from 'remix/ui/animation'
let animation = tween({
from: 0,
to: 100,
duration: 300,
curve: easings.easeOut,
})
animation.next() // initialize
function tick(timestamp: number) {
if (handle.signal.aborted) return
let { value, done } = animation.next(timestamp)
element.style.transform = `translateX(${value}px)`
if (!done) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)Built-in easings: easings.linear, easings.ease, easings.easeIn, easings.easeOut, easings.easeInOut.
Practical Guidance
- Always key conditional or switching elements you expect to animate.
- Use
animateLayoutonly on the element whose position or size changes. - Prefer one clear transition intent per mixin: entrance starts from a style, exit ends at a style.
- Default to
...spring()for duration and easing in most cases. - Keep DOM work in
handle.queueTask(...)orref(...), not in render.
Assets and Browser Modules
What This Covers
How to serve browser scripts and styles from source. Read this when the task involves:
- Configuring
createAssetServer(basePath,fileMap,allow,deny, fingerprinting, compiler options) - Choosing between
staticFiles()for already-built files andcreateAssetServer()for source assets that need import rewriting, preloads, or fingerprinted URLs - Generating script URLs or
<link rel="modulepreload">tags for a client entry - Keeping server-only files out of the browser via
denyrules
For routing the URL namespace itself, see routing-and-controllers.md. For client entry hydration, see hydration-frames-navigation.md.
When To Reach For It
Use remix/assets when the app serves browser JavaScript, TypeScript, or CSS from source files. This is the right tool for client entrypoints, browser-only helpers, styles under app/assets/, and monorepo code that should be compiled and served under a public URL namespace.
Use staticFiles() for files that already exist on disk exactly as they should be served. Use createAssetServer() for source scripts or styles that need rewriting, dependency scanning, preloads, sourcemaps, or fingerprinted URLs.
Default Pattern
import { createAssetServer } from 'remix/assets'
import { createController } from 'remix/router'
import { get, route } from 'remix/routes'
export const routes = route({
assets: get('/assets/*path'),
})
let assetServer = createAssetServer({
basePath: '/assets',
rootDir: process.cwd(),
fileMap: {
'app/*path': 'app/*path',
'node_modules/*path': 'node_modules/*path',
},
allow: ['app/assets/**', 'node_modules/**'],
deny: ['app/**/*.server.*'],
target: { es: '2020', chrome: '109', safari: '16.4' },
sourceMaps: process.env.NODE_ENV === 'development' ? 'external' : undefined,
minify: process.env.NODE_ENV === 'production',
scripts: {
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'development'),
},
},
})
export default createController(routes, {
actions: {
async assets({ request }) {
return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
},
},
})Rules
- Treat
allowanddenyas the security boundary for browser-reachable source files. - Add a
denylist for server-only modules such as*.server.*, private config, or other files that should never be exposed. - Set
rootDirexplicitly in monorepos so relative paths resolve from the intended project root. basePathis the public URL namespace handled by the asset server.fileMapkeys are URL patterns relative tobasePath, and values are root-relative file path patterns. They useroute-patternsyntax on both sides.- Keep the same wildcard params on both sides of a
fileMapentry so import rewriting can map source files back to public URLs. - CSS files are compiled and served alongside scripts. Local CSS
@importrules are rewritten and fingerprinted with the same asset server routing rules.
Rendering HTML
Use getHref() when you need the public URL for one module, and getPreloads() when you want <link rel="modulepreload"> tags or Link headers for one or more entrypoints and their dependencies.
let entryHref = await assetServer.getHref('app/assets/entry.ts')
let preloads = await assetServer.getPreloads(['app/assets/entry.ts'])Use this when rendering documents or layouts that boot browser behavior with a known client entry.
When resolving hydrated client entries during server rendering, pass the source entry ID from clientEntry(import.meta.url, ...) to getHref() inside resolveClientEntry. Keep export-name resolution in that render helper, and avoid hard-coding public asset URLs in source-owned component modules.
Development vs Deployment
In development:
- Keep
watchenabled so source changes are picked up without restarting the server - Prefer stable URLs with normal revalidation
- Enable source maps when debugging browser code
In deployment:
- Set
watch: false - Use
fingerprint: { buildId }for long-lived immutable caching - Make sure
buildIdchanges for each deploy
Fingerprinting assumes files on disk are stable and requires watch: false.
Useful Compiler Options
minifyfor production minification of scripts and stylessourceMapsfor'external'or'inline'source maps for scripts and stylessourceMapSourcePathsfor'url'or'absolute'source map pathstargetas an object for shared browser targets and script-only ECMAScript output, such as{ es: '2020', chrome: '109', safari: '16.4' }scripts.defineto replace globals such asprocess.env.NODE_ENVscripts.externalto leave specific script imports untouched
Do not nest shared compiler options under scripts. Use top-level minify, sourceMaps, sourceMapSourcePaths, and target so they apply to styles as well as scripts.
Lifecycle
If the asset server is long-lived and watching the file system, call await assetServer.close() when shutting down dev servers or disposing tests.
Authentication and Sessions
What This Covers
How to remember things about a browser between requests and how to identify a user. Read this when the task involves:
- Storing per-browser state across requests (login, cart, "I have submitted this form")
- Adding a credentials login flow or an OAuth provider
- Protecting routes with
requireAuth()or stacking authorization checks - Reading or writing
Session,Auth, or other identity-related context values - Logging in, logging out, or rotating session IDs
For raw cookies that are not session-backed (theme, locale, dismissed-banner), see createCookie in this file plus the broader Package Map in SKILL.md.
Sessions vs Plain Cookies
Reach for remix/session when state is sensitive, must be tamper-resistant, or represents the identity of a request: who is logged in, which form a browser already submitted, what items are in a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a typed Session object you can get, set, flash, unset, and regenerateId.
Reach for remix/cookie directly when the browser is allowed to carry the value and the server does not need session semantics. This often means preferences (theme, locale, dismissed banner), but a signed cookie can also be fine for small low-risk values where you truly only need one cookie-shaped fact and do not need Session helpers.
If a malicious user editing the value would be a bug, or if the value needs server-managed lifecycle, reach for a session.
Quick chooser
| Need | Best fit | Why |
|---|---|---|
| Theme, locale, dismissed banner | remix/cookie | Browser-controlled preference |
| Small signed hint with minimal lifecycle | remix/cookie | One value, no Session helpers needed |
| "This browser already submitted", cart, flash messages, login state | remix/session | Tamper-sensitive, server-managed per-browser state |
| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
Session Setup
Create a session cookie
import { createCookie } from 'remix/cookie'
let sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret && process.env.NODE_ENV !== 'test') {
throw new Error('SESSION_SECRET is required')
}
export let sessionCookie = createCookie('session', {
secrets: [sessionSecret ?? 'test-only-secret'],
httpOnly: true,
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 2592000, // 30 days
path: '/',
})The cookie should always be httpOnly, default to sameSite: 'Lax', and be secure in production. Demo defaults like 's3cr3t' are fine in tests but should never reach production — fail fast when the secret is missing.
Create session storage
// Filesystem storage
import { createFsSessionStorage } from 'remix/session-storage/fs'
export let sessionStorage = createFsSessionStorage('./tmp/sessions')
// Memory storage (for tests)
import { createMemorySessionStorage } from 'remix/session-storage/memory'
export let sessionStorage = createMemorySessionStorage()Add session middleware
import { session } from 'remix/middleware/session'
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
// ... other middleware
],
})Using sessions in handlers
import { Session } from 'remix/session'
async function handler({ get }) {
let session = get(Session)
// Read
let userId = session.get('userId')
// Write
session.set('userId', 42)
// Flash (read once, then cleared)
session.flash('message', 'Settings saved!')
let message = session.get('message') // returns and clears
// Remove a key
session.unset('userId')
// Regenerate session ID (after login/logout)
session.regenerateId(true)
}Sessions for non-auth state
Sessions are not just for login. They are the right place to store any tamper-sensitive per-browser fact: which form a browser already submitted, how many free actions are left in a trial, which feature flags a tester opted into, what items are in a cart.
async function submit({ get }) {
let session = get(Session)
if (session.get('hasSubmitted')) {
return render(<AlreadySubmittedPage />, { status: 409 })
}
let parsed = s.parseSafe(submitSchema, get(FormData))
if (!parsed.success) {
return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
}
await saveSubmission(parsed.value)
session.set('hasSubmitted', true)
session.flash('message', 'Thanks for submitting!')
return redirect(routes.thanks.href())
}Notice that there is no manual Set-Cookie plumbing in the action — the session middleware handles that, and the handler returns an ordinary Response. Per-browser state enforced this way is still bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account (see auth providers below).
Auth Middleware
Basic setup
import { auth, createSessionAuthScheme } from 'remix/middleware/auth'
import { Session } from 'remix/session'
import { Database } from 'remix/data-table'
export function loadAuth() {
return auth({
schemes: [
createSessionAuthScheme({
read(session) {
let data = session.get('auth')
return data ?? null
},
async verify(value, context) {
let db = context.get(Database)
return (await db.find(users, value.userId)) ?? null
},
invalidate(session) {
session.unset('auth')
},
}),
],
})
}Reading auth state
import { Auth } from 'remix/middleware/auth'
function handler({ get }) {
let auth = get(Auth)
if (auth.ok) {
// User is authenticated
let user = auth.identity
}
}Credentials Auth
Define a credentials provider
import { createCredentialsAuthProvider, verifyCredentials, completeAuth } from 'remix/auth'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
let loginSchema = f.object({
email: f.field(s.defaulted(s.string(), '')),
password: f.field(s.defaulted(s.string(), '')),
})
export let passwordProvider = createCredentialsAuthProvider({
parse(context) {
let formData = context.get(FormData)
return s.parse(loginSchema, formData)
},
async verify({ email, password }, context) {
let db = context.get(Database)
let user = await db.findOne(users, { where: { email } })
if (!user || !(await verifyPassword(password, user.password_hash))) {
return null
}
return user
},
})Login action
import { verifyCredentials, completeAuth } from 'remix/auth'
import { redirect } from 'remix/response/redirect'
async action(context) {
let user = await verifyCredentials(passwordProvider, context)
if (user == null) {
let session = context.get(Session)
session.flash('error', 'Invalid email or password.')
return redirect(routes.auth.login.href())
}
let session = completeAuth(context)
session.set('auth', { userId: user.id })
return redirect(routes.home.href())
},Logout action
import { Session } from 'remix/session'
import { redirect } from 'remix/response/redirect'
function logout(context) {
let session = context.get(Session)
session.unset('auth')
session.regenerateId(true)
return redirect(routes.home.href())
}OAuth / External Auth
Create providers
import {
createAtmosphereAuthProvider,
createGoogleAuthProvider,
createGitHubAuthProvider,
startExternalAuth,
finishExternalAuth,
completeAuth,
refreshExternalAuth,
} from 'remix/auth'
let googleProvider = createGoogleAuthProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: new URL(routes.auth.google.callback.href(), origin),
})
let githubProvider = createGitHubAuthProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
redirectUri: new URL(routes.auth.github.callback.href(), origin),
})
let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
throw new Error('ATMOSPHERE_SESSION_SECRET is required')
}
let atmosphereProvider = createAtmosphereAuthProvider({
clientId: 'https://app.example.com/oauth/client-metadata.json',
redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
})For Atmosphere-compatible atproto OAuth, create the provider once, call atmosphereProvider.prepare(handleOrDid) before startExternalAuth(...), then pass the same module-scope provider to finishExternalAuth(...) and refreshExternalAuth(...).
OAuth controller
import { createController } from 'remix/router'
export default createController(routes.auth.google, {
actions: {
// GET /auth/google — redirect to Google
async index(context) {
return await startExternalAuth(googleProvider, context, {
returnTo: context.url.searchParams.get('returnTo'),
})
},
// GET /auth/google/callback — handle redirect back
async callback(context) {
let { result, returnTo } = await finishExternalAuth(googleProvider, context)
let db = context.get(Database)
let { user, authAccount } = await resolveExternalAuth(db, result)
let session = completeAuth(context)
session.set('auth', {
userId: user.id,
loginMethod: result.provider,
authAccountId: authAccount.id,
})
return redirect(returnTo ?? routes.account.index.href())
},
},
})Refresh stored provider tokens
Use refreshExternalAuth(provider, tokens) when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.
async function refreshGoogleTokens({ get }) {
let db = get(Database)
let account = await db.findOne(authAccounts, { where: { provider: 'google' } })
if (!account) return null
let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
return refreshed.tokens
}Protecting Routes
Controller middleware protection
Apply requireAuth() as controller middleware to every action in one controller:
import { createController } from 'remix/router'
import { requireAuth } from 'remix/middleware/auth'
export default createController(routes.account, {
middleware: [requireAuth()],
actions: {
index() {
/* guaranteed authenticated */
},
},
})Nested route maps need their own explicit protection:
// app/router.ts
router.map(routes.account, accountController)
router.map(routes.account.settings, accountSettingsController)
// app/actions/account/settings/controller.tsx
export default createController(routes.account.settings, {
middleware: [requireAuth()],
actions: {
index() {
/* guaranteed authenticated */
},
update() {
/* guaranteed authenticated */
},
},
})Stacking middleware
Combine auth checks with role checks:
export default createController(routes.admin, {
middleware: [requireAuth(), requireAdmin()],
actions: {
index() {
/* requires auth + admin */
},
},
})Action middleware protection
Apply middleware to a single route:
import { Auth, requireAuth } from 'remix/middleware/auth'
router.get(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
let auth = context.get(Auth)
return render(<AccountPage identity={auth.identity} />)
},
})Redirect on auth failure
import { requireAuth } from 'remix/middleware/auth'
import { redirect } from 'remix/response/redirect'
export function requireAuthRedirect() {
return requireAuth({
onFailure(context) {
let returnTo = encodeURIComponent(context.url.pathname)
return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
},
})
}Component Model
What This Covers
How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when the task involves:
- Writing a component (
handleplus render function) - Managing component-local state, derived values, or post-render DOM work
- Using
handle.props,handle.update(),handle.queueTask(),handle.signal,handle.id, orhandle.context - Listening to global events with cleanup tied to the component lifecycle
For host-element behavior (event handlers, styles, refs, animations), see mixins-styling-events.md. For browser hydration, frames, and navigation, see hydration-frames-navigation.md.
Phases
A component has two phases:
1. Setup phase — runs once when the component is created 2. Render phase — returned zero-argument function runs on initial render and every update
The component shape is function Component(handle: Handle<Props>) { return () => ... }. Props are available as handle.props in setup scope and are updated before every render.
import { on, type Handle } from 'remix/ui'
function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
mix={on('click', () => {
count++
handle.update()
})}
>
{handle.props.label}: {count}
</button>
)
}Props
Components receive all JSX props through handle.props. The object identity is stable for the component lifetime, and its values are updated before each render. Put initialization inputs on normal JSX props and read them from handle.props:
function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
let seconds = handle.props.initialSeconds
return () => <div>Time remaining: {seconds}s</div>
}
// Usage: <Timer initialSeconds={60} paused={false} />Because handle.props is stable, destructuring let { props } = handle is safe when helpers need to read current values later. Destructuring individual prop values is only a snapshot; prefer handle.props.name inside callbacks and render output when values can change.
State Rules
- Keep state in setup scope as plain JavaScript variables.
- Store only what affects rendering. Derive computed values in render.
- Do not mirror input state unless you truly need controlled behavior.
- Do work in event handlers, not in render. Use the handler scope for transient state.
// Derive computed values in render
function TodoList(handle: Handle) {
let todos: Array<{ text: string; completed: boolean }> = []
return () => {
let completedCount = todos.filter((t) => t.completed).length
return <div>Completed: {completedCount}</div>
}
}Handle API
handle.update()
Schedules a rerender. Returns a promise that resolves with an AbortSignal after the update completes. Await it when you need the updated DOM before follow-up work:
on('click', async () => {
isPlaying = true
let signal = await handle.update()
// DOM is now updated, safe to focus or measure
stopButton.focus()
})handle.queueTask(task)
Schedules a task to run after the next update. The task receives an AbortSignal that aborts when the component re-renders or is removed. Use for post-render DOM work, reactive data loading, or hydration-sensitive setup:
let data = null
let requestedUrl: string | null = null
// Post-render DOM work in an event handler
on('click', () => {
showDetails = true
handle.update()
handle.queueTask(() => {
detailsSection.scrollIntoView({ behavior: 'smooth' })
})
})
// Reactive data loading keyed by props.url
return () => {
if (requestedUrl !== handle.props.url) {
let nextUrl = handle.props.url
requestedUrl = nextUrl
data = null
handle.queueTask(async (signal) => {
let response = await fetch(nextUrl, { signal })
let json = await response.json()
if (signal.aborted || requestedUrl !== nextUrl) return
data = json
handle.update()
})
}
return <div>{data ?? 'Loading...'}</div>
}Avoid creating intermediate state just to trigger queueTask. Do the work directly in the handler or the queued task.
handle.signal
An AbortSignal aborted when the component disconnects. Use for cleanup:
function Clock(handle: Handle) {
let interval = setInterval(handle.update, 1000)
handle.signal.addEventListener('abort', () => clearInterval(interval))
return () => <span>{new Date().toString()}</span>
}handle.id
Stable identifier per component instance. Useful for htmlFor, aria-owns, etc.:
function LabeledInput(handle: Handle) {
return () => (
<div>
<label htmlFor={handle.id}>Name</label>
<input id={handle.id} type="text" />
</div>
)
}handle.frame and handle.frames
Frame-aware behavior for client entries rendered inside frames:
handle.frame.reload()— reload the containing framehandle.frame.src— the URL of the containing framehandle.frames.top— the root frame (the whole page)handle.frames.top.reload()— reload the entire page/frame treehandle.frames.get(name)— look up a named frame; returnsFrameHandle | undefined
function RefreshButton(handle: Handle) {
return () => <button mix={on('click', () => handle.frame.reload())}>Refresh</button>
}handle.context
Context for ancestor/descendant communication. See the context section below.
Context
Use handle.context.set() to provide values and handle.context.get(Provider) to consume them. set() does not trigger updates — call handle.update() if the tree needs to rerender.
function ThemeProvider(handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>) {
let theme: 'light' | 'dark' = 'light'
handle.context.set({ theme })
return () => (
<div>
<button
mix={on('click', () => {
theme = theme === 'light' ? 'dark' : 'light'
handle.context.set({ theme })
handle.update()
})}
>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let { theme } = handle.context.get(ThemeProvider)
return () => <div>Current theme: {theme}</div>
}For granular updates without re-rendering the full subtree, use TypedEventTarget:
import { TypedEventTarget, addEventListeners } from 'remix/ui'
class Theme extends TypedEventTarget<{ change: Event }> {
#value: 'light' | 'dark' = 'light'
get value() {
return this.#value
}
setValue(value: 'light' | 'dark') {
this.#value = value
this.dispatchEvent(new Event('change'))
}
}
function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
let theme = new Theme()
handle.context.set(theme)
return () => (
<div>
<button mix={on('click', () => theme.setValue(theme.value === 'light' ? 'dark' : 'light'))}>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
addEventListeners(theme, handle.signal, {
change() {
handle.update()
},
})
return () => <div>Theme: {theme.value}</div>
}Global Events
Use addEventListeners(target, handle.signal, listeners) to listen to global targets with automatic cleanup when the component disconnects:
import { addEventListeners, type Handle } from 'remix/ui'
function ResizeTracker(handle: Handle) {
let width = window.innerWidth
addEventListeners(window, handle.signal, {
resize() {
width = window.innerWidth
handle.update()
},
})
return () => <div>{width}</div>
}Creating Mixins
What This Covers
How to author your own reusable host-element behavior with createMixin. Read this when the task involves:
- Combining multiple low-level events or DOM hooks into one semantic mixin
- Dispatching custom DOM events from a host node
- Encapsulating imperative DOM setup that several components share
- Typing custom events on
HTMLElementEventMapfor use withon(...)
For the built-in mixins most code should use, see mixins-styling-events.md.
Use createMixin from remix/ui to author reusable host-element behavior.
Most app code should use built-in core mixins (on, css, ref, link, attrs) and animation mixins from remix/ui/animation. Create custom mixins when combining multiple low-level events into one semantic event, or when the pattern is reused across components.
Core Semantics
1. A mixin handle is tied to one mounted host node lifecycle. 2. insert is the host-node availability point for imperative setup. 3. remove is teardown for that same lifecycle. 4. queueTask runs post-commit and receives (node, signal) for mixins. 5. Mixin render functions should stay pure; side effects belong in insert, remove, or queued work.
import { createMixin } from 'remix/ui'
let myMixin = createMixin<HTMLElement>((handle) => {
handle.addEventListener('insert', (event) => {
// event.node is the mounted host node
})
handle.addEventListener('remove', () => {
// Clean up listeners, timers, observers
})
return (props) => {
handle.queueTask((node) => {
// Post-commit work that needs the concrete host node
})
return <handle.element {...props} />
}
})Patterns
Pure prop transform
let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => (
<handle.element {...props} title={title} />
))Lifecycle-managed imperative setup
let withFocus = createMixin<HTMLElement>((handle) => {
handle.addEventListener('insert', (event) => {
event.node.focus()
})
return (props) => <handle.element {...props} />
})Custom Event Mixins
Create event mixins when you combine multiple low-level events into one semantic custom event that is reused across components.
1. Namespace custom event names (myapp:*) to avoid collisions. 2. Extend Event with the data consumers need. 3. Declare the event on HTMLElementEventMap for type safety with on(...). 4. Dispatch from the host node inside the mixin.
import { createMixin, on } from 'remix/ui'
export let dragReleaseType = 'myapp:drag-release' as const
declare global {
interface HTMLElementEventMap {
[dragReleaseType]: DragReleaseEvent
}
}
export class DragReleaseEvent extends Event {
velocityX: number
velocityY: number
constructor(init: { velocityX: number; velocityY: number }) {
super(dragReleaseType, { bubbles: true, cancelable: true })
this.velocityX = init.velocityX
this.velocityY = init.velocityY
}
}
export let dragRelease = createMixin<HTMLElement>((handle) => {
let node: HTMLElement | undefined
let tracking = false
let velocityX = 0
let velocityY = 0
let lastX = 0
let lastY = 0
let lastT = 0
handle.addEventListener('insert', (event) => {
node = event.node
})
return () => (
<handle.element
mix={[
on('pointerdown', (event) => {
if (!event.isPrimary) return
tracking = true
lastX = event.clientX
lastY = event.clientY
lastT = event.timeStamp
node?.setPointerCapture(event.pointerId)
}),
on('pointermove', (event) => {
if (!tracking) return
let dt = Math.max(1, event.timeStamp - lastT)
velocityX = (event.clientX - lastX) / dt
velocityY = (event.clientY - lastY) / dt
lastX = event.clientX
lastY = event.clientY
lastT = event.timeStamp
}),
on('pointerup', () => {
if (!tracking) return
tracking = false
node?.dispatchEvent(new DragReleaseEvent({ velocityX, velocityY }))
}),
]}
/>
)
})Consume it:
<div
mix={[
dragRelease(),
on(dragReleaseType, (event) => {
console.log('velocity:', event.velocityX, event.velocityY)
}),
]}
/>Data Access and Validation
What This Covers
How input becomes a value the app trusts, and how that value reaches storage. Read this when the task involves:
- Defining database tables, columns, relations, and migrations
- Querying or mutating persisted data with
Database - Parsing and validating user input from forms, query strings, or external payloads
- Choosing between schema-level checks, table validation hooks, and migration-level constraints
For where validation runs in the request lifecycle, see routing-and-controllers.md. For session or identity-bound writes, see auth-and-sessions.md.
Table Definitions (remix/data-table)
Define tables with typed columns, relations, and optional validation hooks:
import { belongsTo, column as c, hasMany, table } from 'remix/data-table'
import type { TableRow, TableRowWith } from 'remix/data-table'
export const books = table({
name: 'books',
columns: {
id: c.integer().primaryKey().autoIncrement(),
slug: c.text().notNull().unique(),
title: c.text().notNull(),
author: c.text().notNull(),
price: c.decimal(10, 2).notNull(),
genre: c.text().notNull(),
in_stock: c.boolean(),
},
})
export const orders = table({
name: 'orders',
columns: {
id: c.integer().primaryKey().autoIncrement(),
user_id: c.integer().notNull().references('users', 'id'),
total: c.decimal(10, 2).notNull(),
created_at: c.integer().notNull(),
},
relations: {
user: belongsTo('users', 'user_id'),
items: hasMany('order_items', 'order_id'),
},
})
export type Book = TableRow<typeof books>
export type Order = TableRow<typeof orders>
export type OrderWithItems = TableRowWith<typeof orders, 'items'>Column types
| Method | SQL type |
|---|---|
c.integer() | INTEGER |
c.text() | TEXT |
c.boolean() | BOOLEAN |
c.decimal(precision, scale) | DECIMAL |
c.enum([...]) | TEXT (string enum) |
c.uuid() | UUID / TEXT |
c.varchar(length) | VARCHAR |
Column modifiers: .primaryKey(), .autoIncrement(), .notNull(), .unique(), .references(table, column, fkName?), .onDelete(action), .default(value).
Composite primary keys go on the table option, not the column: primaryKey: ['order_id', 'book_id'].
Schema vs migrations
Column modifiers on runtime table(...) definitions in app/data/schema.ts describe app-facing column metadata. They do not create or update database tables by themselves. The source of truth for actual DDL and constraints is your hand-written SQL migration files. Two valid patterns:
- Mirror constraints in schema and SQL — table definitions stay useful as schema-level docs, and migrations still own the actual DDL.
- Bare columns in schema, constraints in SQL — schema describes what the app reads and writes; migrations own the DDL and constraints.
Pick one and apply it consistently across the app.
Table lifecycle hooks
Tables can define validation and lifecycle hooks:
validateruns beforecreateandupdatewrites and should return either{ value }or{ issues }beforeWritecan normalize or vetocreate/updatevaluesafterWriteobserves completedcreate/updateoperationsbeforeDeleteandafterDeleteobserve or veto deletesafterReadcan normalize or reject row values after reads
export const books = table({
name: 'books',
columns: {
/* ... */
},
beforeWrite({ value }) {
if (typeof value.slug === 'string') {
return { value: { ...value, slug: value.slug.trim().toLowerCase() } }
}
return { value }
},
validate({ operation, value }) {
let issues = []
if (operation === 'create' && !value.slug) {
issues.push({ message: 'Slug is required.', path: ['slug'] })
}
return issues.length > 0 ? { issues } : { value }
},
afterRead({ value }) {
return { value }
},
})Database Setup
Create a database with an adapter and expose it via middleware:
import BetterSqlite3 from 'better-sqlite3'
import { createDatabase, Database } from 'remix/data-table'
import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
let sqlite = new BetterSqlite3('./db/app.db')
sqlite.pragma('foreign_keys = ON')
let adapter = createSqliteDatabaseAdapter(sqlite)
export let db = createDatabase(adapter)createSqliteDatabaseAdapter accepts synchronous SQLite clients with a shared prepare/exec surface, including Node's node:sqlite, Bun's bun:sqlite, and compatible clients. Use whichever client fits the runtime instead of assuming better-sqlite3 is required.
Database middleware
import type { Middleware } from 'remix/router'
import { Database } from 'remix/data-table'
export function loadDatabase(): Middleware {
return async (context, next) => {
context.set(Database, db)
return next()
}
}Querying
let db = get(Database)
// Find by primary key
let book = await db.find(books, id)
// Find one by condition
let user = await db.findOne(users, { where: { email } })
// Find many with ordering
let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] })
// Count
let total = await db.count(orders, { where: { user_id: userId } })
// Query builder
let genres = await db.query(books).select('genre').distinct().orderBy('genre', 'asc').all()
// Create
let newBook = await db.create(books, { slug: 'new-book', title: 'New Book' /* ... */ })
// Update
await db.update(books, bookId, { title: 'Updated Title' })
// Delete
await db.delete(books, bookId)Operators
import { inList } from 'remix/data-table/operators'
let featured = await db.findMany(books, {
where: inList('slug', ['book-a', 'book-b', 'book-c']),
})Migrations
Migrations are plain SQL files. Each migration is a directory named YYYYMMDDHHmmss_<slug>/ containing a hand-written up.sql (required) and an optional down.sql (omit for irreversible migrations).
db/
migrations/
20260228090000_create_users/
up.sql
down.sql
20260301083000_add_books_search_index/
up.sqlWriting migrations
Write standard SQL in up.sql and down.sql:
-- up.sql
create table users (
id integer primary key autoincrement,
email text not null unique,
name text not null
);
create index users_email_idx on users (email);-- down.sql
drop table if exists users;Do not import app code (e.g. app/data/schema.ts) into migration files. Migrations must be stable, immutable artifacts — importing live schema definitions creates drift between what the migration meant when it was written and what it does when replayed later. SQL files guarantee stability because they cannot import anything.
Transaction modes
Migrations run inside a transaction by default (when the adapter supports transactional DDL). Override per migration with a directive comment in up.sql:
-- data-table/transaction: none
create index concurrently users_email_idx on users (email);Modes: auto (default — wrap when supported), required (wrap; throw if unsupported), none (never wrap).
Running migrations
import { createMigrationRunner } from 'remix/data-table/migrations'
import { loadMigrations } from 'remix/data-table/migrations/node'
let migrations = await loadMigrations('./db/migrations')
let runner = createMigrationRunner(adapter, migrations)
await runner.up()The runner checksums each up.sql and detects drift if a previously applied migration changes. Use runner.status() to inspect applied/pending/drifted state, and runner.down() to revert.
Input Validation (remix/data-schema)
Use data-schema to validate user input (forms, query params, API payloads). This is separate from table-level validate hooks which run at persistence.
Schema builders
import * as s from 'remix/data-schema'
import { email, minLength, maxLength } from 'remix/data-schema/checks'
let userSchema = s.object({
name: s.string().pipe(minLength(1)),
email: s.string().pipe(email()),
age: s.optional(s.number()),
})
let result = s.parse(userSchema, data)FormData validation
Use remix/data-schema/form-data to validate FormData directly:
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import { email, minLength } from 'remix/data-schema/checks'
let signupSchema = f.object({
name: f.field(s.string().pipe(minLength(1))),
email: f.field(s.string().pipe(email())),
password: f.field(s.string().pipe(minLength(8))),
})
// In a controller action:
let formData = get(FormData)
let { name, email, password } = s.parse(signupSchema, formData)Reading FormData: middleware vs request.formData()
There are two ways to get a FormData value inside an action.
The recommended way: register formData() middleware in the root stack and read with get(FormData). The body is parsed once per request, and the typed FormData value flows through the context system. This also lets methodOverride() and CSRF middleware work uniformly.
import { formData } from 'remix/middleware/form-data'
let router = createRouter({
middleware: [, /* ... */ formData() /* ... */],
})
// In an action:
let parsed = s.parseSafe(signupSchema, get(FormData))The fallback: await request.formData() directly. This works without middleware and is fine for small one-off cases, but it bypasses the context system, runs once per call site, and doesn't compose with middleware that depends on parsed form fields.
Safe parsing
s.parse throws on invalid input. s.parseSafe returns a tagged result and is usually what an action wants, since validation failure is an expected outcome (re-render the form with errors) rather than an exception:
let result = s.parseSafe(signupSchema, get(FormData))
if (!result.success) {
return render(<SignupPage errors={result.issues} />, { status: 400 })
}
let { name, email, password } = result.valueReturning a Response for validation failures keeps the route contract honest: the same action returns 200 on success, 400 with errors on bad input, no out-of-band exception flow.
Transforming validated output
Use .transform(...) when a schema should validate one shape but return another value or output type. Transforms run after validation and compose with .pipe(...) and .refine(...):
import * as coerce from 'remix/data-schema/coerce'
let slugSchema = s
.string()
.pipe(minLength(1))
.transform((value) => value.trim().toLowerCase().replace(/\s+/g, '-'))
let pageSchema = f.object({
page: f.field(s.defaulted(coerce.coerceNumber(), 1).refine(Number.isInteger)),
q: f.field(s.defaulted(s.string(), '').transform((value) => value.trim())),
})
let { page, q } = s.parse(pageSchema, formData)Anti-patterns
Avoid these shapes when reading and validating input:
- Raw `formData.get('name')` plus an `if (typeof name !== 'string')` guard, then a thrown custom error. This reinvents what
data-schemaalready does, loses the typed result, and pushes error translation into atry/catchinstead of a return value. - Letting route-local domain errors leak out of the action. Translate expected outcomes (bad input, missing record, duplicate entry) into the
Responsethe route means to return instead of throwing a customErrorsubclass with astatusfield and catching it later. - Trusting `params`, query strings, or external payloads without a schema. Anything that crosses a trust boundary should be parsed before it reaches business logic.
Common patterns
// Optional with default
let limitSchema = f.field(s.defaulted(s.string(), '10'))
// Union types
let methodSchema = s.union([s.literal('credentials'), s.literal('google'), s.literal('github')])
// Refinements
let idSchema = s.number().refine(Number.isInteger, 'Expected an integer')Hydration, Frames, and Navigation
What This Covers
How server-rendered UI becomes interactive in the browser, and how the page updates without a full navigation. Read this when the task involves:
- Marking a component for client-side hydration with
clientEntry - Booting the client runtime with
run - Streaming server content into a region of the page with
<Frame>and reloading those regions - Triggering Navigation API transitions with
navigate(...)orlink(...) - Server rendering with
renderToStreamorrenderToString - Managing the document
<head>
For component-local state and updates, see component-model.md. For host-element behavior and events, see mixins-styling-events.md.
Server First, Then Hydrate
Make the server route correct before adding clientEntry(...). A POST should already do the right thing on its own — return HTML, a redirect, or an error response — and a GET should already render the page the user expects. clientEntry exists to layer interactivity on top of UI that already works without it.
When server state changes after a mutation, prefer reloading a <Frame> when the UI region already maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic stays in one place and the client does not need a parallel "state" API.
on('submit', async (event, signal) => {
event.preventDefault()
await fetch(routes.cart.add.href(), {
method: 'POST',
body: new FormData(event.currentTarget),
signal,
})
if (signal.aborted) return
await handle.frames.get('cart-summary')?.reload()
})Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves clear ownership of rendering logic.
Client Entries
Use clientEntry to mark a component for client-side hydration. In source-served apps, prefer the source module's import.meta.url as the entry ID and let server rendering map it to the public asset URL:
import { clientEntry, on, type Handle } from 'remix/ui'
export const Counter = clientEntry(
import.meta.url,
function Counter(handle: Handle<{ initialCount: number; label: string }>) {
let count = handle.props.initialCount
return () => (
<div>
<span>
{handle.props.label}: {count}
</span>
<button
mix={on('click', () => {
count++
handle.update()
})}
>
+
</button>
</div>
)
},
)On the server, provide resolveClientEntry to renderToStream(...) so source file URLs become browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not hard-code deployment-specific asset paths:
let stream = renderToStream(<App />, {
async resolveClientEntry(entryId, component) {
let exportName = entryId.split('#')[1] || component.name
if (!exportName) {
throw new Error(`Unable to resolve client entry export for ${entryId}`)
}
return {
href: await assetServer.getHref(entryId),
exportName,
}
},
})If the module export name differs from the component function name, include #ExportName in the entry ID or return the exact export name from resolveClientEntry. A render helper that only supports source-owned entries can also fail fast when entryId is not a file:// URL.
On the server, clientEntry components render like any other component. The server wraps their output in comment markers and serializes props into a <script type="application/json"> tag.
Client entry props must be serializable: strings, numbers, booleans, null, undefined, plain objects/arrays of the above, JSX elements, and <Frame> elements. Functions and class instances cannot be passed.
Booting the Client
Use run to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
import { run } from 'remix/ui'
let app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl)
return mod[exportName]
},
async resolveFrame(src, signal, target) {
let headers = new Headers({ accept: 'text/html' })
if (target) headers.set('x-remix-target', target)
let response = await fetch(src, { headers, signal })
return response.body ?? (await response.text())
},
})
app.addEventListener('error', (event) => {
console.error('Component error:', event.error)
})
await app.ready()run options
- `loadModule(moduleUrl, exportName)` (required) — return the component function for each client entry. Typically uses dynamic
import(). - `resolveFrame(src, signal, target)` (optional) — called when a
<Frame>loads or reloads content.targetis available when frame targeting matters.
app methods
- `app.ready()` — resolves when all initial client entries are hydrated
- `app.flush()` — synchronously flushes all pending updates
- `app.dispose()` — tears down all hydrated components
app is an EventTarget that emits error events from any hydrated component.
Frames
A <Frame> renders server content into the page. Frames stream after the initial HTML, nest inside other frames, contain client entries, and can be reloaded without full page navigation.
import { Frame } from 'remix/ui'
function App() {
return () => (
<div>
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
<Frame name="main" src="/main-content" />
</div>
)
}Frame props
- `src` (required) — URL to fetch the frame content from
- `fallback` (optional) — content to show while loading; determines streaming behavior
- `name` (optional) — registers the frame for lookup via
handle.frames.get(name) - `on` (optional) — event handlers for events dispatched from the frame element
Blocking vs non-blocking
- Without `fallback` (blocking) — the server waits for frame content before sending the initial HTML chunk
- With `fallback` (non-blocking) — the fallback renders immediately; real content streams in later and replaces it
Reloading frames
Client entries inside a frame can trigger a reload:
// Reload the containing frame
handle.frame.reload()
// Reload an adjacent named frame
await handle.frames.get('cart-summary')?.reload()
// Reload the entire page/frame tree
handle.frames.top.reload()When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props while preserving their local component state.
Nested frames
Frames can nest. Each frame owns its own DOM region and hydrates client entries independently. During SSR, handle.frame.src points at the frame being rendered, while handle.frames.top.src stays fixed at the outer document URL.
Server Rendering
renderToStream
Renders a component tree to a ReadableStream<Uint8Array>. Sends initial HTML immediately and streams frame content as it resolves:
import { renderToStream } from 'remix/ui/server'
let stream = renderToStream(<App />, {
frameSrc: request.url,
resolveFrame(src, target, context) {
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
return fetchHtml(frameUrl)
},
onError(error) {
console.error(error)
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})Options:
- `frameSrc` — seeds SSR frame state; populates
handle.frame.srcandhandle.frames.top.src - `topFrameSrc` — overrides the root frame URL for nested frame renders (carry forward from
resolveFramecontext) - `resolveFrame(src, target, context)` — return HTML string,
ReadableStream<Uint8Array>, or a promise of either.context.currentFrameSrcis the containing frame URL;context.topFrameSrcis the outer document URL - `onError(error)` — called on rendering errors
renderToString
Renders a component tree to a complete HTML string. Use for static pages or embedding HTML:
import { renderToString } from 'remix/ui/server'
let html = await renderToString(<App />)CSS in SSR
Components using the css mixin have styles collected during rendering and emitted as a single <style> tag in <head>. No client-side style injection needed.
Navigation
Use real anchors for normal document navigation. For app-driven navigation:
navigate(href, options?)— performs a Navigation API transitionlink(href, options?)mixin — makes any element behave like a navigation link
import { navigate } from 'remix/ui'
navigate('/dashboard', { history: 'replace' })Options: src, target, history ('push' | 'replace'), resetScroll.
Attributes understood by the runtime: rmx-target, rmx-src, rmx-document.
Head Management
Manage document head with an explicit <head> in your document structure:
function App() {
return () => (
<html>
<head>
<title>Dashboard</title>
<meta name="description" content="Team dashboard" />
<link rel="stylesheet" href="/styles/app.css" />
</head>
<body>
<main>...</main>
</body>
</html>
)
}Put title, meta, link, and style tags inside an explicit <head>. Bare head-like tags rendered outside <head> stay where they are — they are not moved into the document head for you.
Middleware and Server Setup
What This Covers
How to compose the request lifecycle and bridge the router to a runtime. Read this when the task involves:
- Choosing or ordering built-in middleware in the stack
- Writing custom middleware that sets typed context values
- Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers (sessions, auth, data loading)
- Choosing when to keep the generated Node server versus switching server adapters
For data and persistence specifics, see data-and-validation.md. For session and auth specifics, see auth-and-sessions.md.
Middleware Stack
Middleware runs in order for every request. Place fast-exit middleware (static files) early and request-enriching middleware (session, auth) later.
Recommended ordering:
import { createRouter } from 'remix/router'
import { compression } from 'remix/middleware/compression'
import { formData } from 'remix/middleware/form-data'
import { logger } from 'remix/middleware/logger'
import { methodOverride } from 'remix/middleware/method-override'
import { session } from 'remix/middleware/session'
import { staticFiles } from 'remix/middleware/static'
import { asyncContext } from 'remix/middleware/async-context'
let middleware = []
if (process.env.NODE_ENV === 'development') {
middleware.push(logger())
}
middleware.push(compression())
middleware.push(staticFiles('./public'))
middleware.push(formData())
middleware.push(methodOverride())
middleware.push(session(cookie, storage))
middleware.push(asyncContext())
middleware.push(loadDatabase())
middleware.push(loadAuth())
let router = createRouter({ middleware })Built-in middleware catalog
| Middleware | Import | Use when | Notes |
|---|---|---|---|
staticFiles(dir, opts?) | remix/middleware/static | Serve files from public/ or another directory exactly as they exist on disk | Fast exit; usually near the top |
compression() | remix/middleware/compression | Compress text-like responses | Usually app-wide |
logger() | remix/middleware/logger | Log requests and responses | Often development-only; colors can force color output on/off |
cors(opts?) | remix/middleware/cors | Endpoints must serve cross-origin browsers or preflight OPTIONS requests | Usually early so preflights can short-circuit |
cop(opts?) | remix/middleware/cop | Reject unsafe cross-origin browser requests without synchronizer tokens | Put before session or CSRF when used |
formData(opts?) | remix/middleware/form-data | Parse FormData bodies, especially forms and uploads | Needed for _csrf form field extraction |
methodOverride() | remix/middleware/method-override | HTML forms need PUT, PATCH, or DELETE semantics | Run after form parsing |
session(cookie, storage) | remix/middleware/session | Cookie-backed sessions | Must run before session-backed auth or CSRF |
csrf(opts?) | remix/middleware/csrf | Session-backed form workflows need synchronizer-token CSRF protection | Requires session() before it |
asyncContext() | remix/middleware/async-context | Helpers outside handlers need request context via getContext() | Add before helpers rely on it |
auth({ schemes }) | remix/middleware/auth | Resolve auth state into context.get(Auth) | Run after session() for session-backed auth |
requireAuth() | remix/middleware/auth | A controller or action must reject anonymous access | Usually controller middleware or action middleware |
Static files vs browser modules
- Use
staticFiles()for files that should be served directly from disk, such as images, fonts, or already-built assets inpublic/ - Use
remix/assetswhen browser modules should be compiled and served from source files with import rewriting, preloads, or fingerprinted URLs
Ordering notes
- Put fast exits early:
staticFiles(),cors()preflight handling, andcop()when used - Parse request bodies before middleware that depends on them, such as
methodOverride()and form field token extraction incsrf() - Run
session()beforecsrf()and before session-backedauth() - Add
asyncContext()before helpers or shared code callgetContext() - Keep route protection like
requireAuth()as controller middleware or action middleware unless the entire app is private
Common stacks
- Session-backed HTML app ->
compression(),staticFiles(), optionalcop(),formData(),methodOverride(),session(), optionalcsrf(),asyncContext(),auth({ schemes }) - Cross-origin API ->
compression(),cors(), optionalasyncContext(), optionalauth({ schemes }) - Upload flow ->
compression(),staticFiles(),formData({ uploadHandler }), then sessions, auth, and data-loading middleware as needed
Middleware with options
// Static files with cache headers
staticFiles('./public', {
cacheControl: 'no-store, must-revalidate',
etag: false,
lastModified: false,
})
// Form data with upload handler
import type { FileUpload } from 'remix/form-data-parser'
import { createFsFileStorage } from 'remix/file-storage/fs'
let fileStorage = createFsFileStorage('./tmp/uploads')
formData({
uploadHandler(fileUpload: FileUpload) {
return fileStorage.set(fileUpload.name, fileUpload)
},
})Errors thrown or rejected by uploadHandler propagate directly. Catch domain-specific upload errors at the route boundary when they should become user-facing Response objects.
Writing Custom Middleware
Middleware is a function that receives (context, next). Return a Response to short-circuit, call and return next() when you need the downstream response, or return nothing when you only set context and want the router to continue automatically.
Setting context values
Use context.set(key, value) to add typed values accessible downstream via context.get(key).
import type { Middleware } from 'remix/router'
import { Database } from 'remix/data-table'
export function loadDatabase(): Middleware {
return async (context, next) => {
context.set(Database, db)
return next()
}
}Guarding routes
import { Auth } from 'remix/middleware/auth'
export function requireAdmin(): Middleware {
return (context, next) => {
let auth = context.get(Auth)
if (auth.identity?.role !== 'admin') {
return new Response('Forbidden', { status: 403 })
}
return next()
}
}Async context for helpers
asyncContext() stores the request context in AsyncLocalStorage so helpers can reach it without the context being threaded through every call. Wrap getContext() in app-specific helpers:
// app/utils/context.ts
import { getContext } from 'remix/middleware/async-context'
import { Auth } from 'remix/middleware/auth'
import { Database } from 'remix/data-table'
import { Session } from 'remix/session'
export function getCurrentDb() {
return getContext().get(Database)
}
export function getCurrentSession() {
return getContext().get(Session)
}
export function getCurrentUser() {
let auth = getContext().get(Auth)
if (!auth.ok) {
throw new Error('Expected an authenticated user. Run requireAuth() before this code.')
}
return auth.identity
}
export function getCurrentUserSafely() {
let auth = getContext().get(Auth)
return auth.ok ? auth.identity : null
}Middleware Types
Middleware has three API-owned forms:
1. Router middleware — runs for every request:
let router = createRouter({ middleware: [logger(), session(cookie, storage)] })2. Controller middleware — runs for the direct actions in one controller:
export default createController(routes.account, {
middleware: [requireAuth()],
actions: { ... },
})Controller middleware does not flow into other controllers. Add the middleware to each controller that needs it.
3. Action middleware — runs for a single action:
router.get(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
return render(<AccountPage identity={context.auth.identity} />)
},
})Prefer inline arrays for middleware options. Use RouterContext<typeof router> to derive an app context from a router that uses inline middleware. Use createMiddleware() only when a chain is stored in a variable and its exact tuple type needs to be preserved, such as when deriving MiddlewareContext<typeof rootMiddleware> without a router value, exporting a reusable chain, or returning a chain from a factory.
Node Server Setup
New apps already include a server.ts that adapts the app router with remix/node-fetch-server. Keep that generated server unless the task specifically needs to change runtime behavior such as host/protocol handling, TLS, HTTP/2, WebSockets, deployment lifecycle, or test-only server setup.
Use remix/node-fetch-server when you want to keep owning a standard Node http, https, or http2 server directly.
Mixins, Styling, and Events
What This Covers
How to attach behavior, styles, and DOM-aware setup to host elements with mix. Read this when the task involves:
- DOM event handling with
on(...) - Static styling with
css(...)and dynamic styling withstyle - Imperative DOM access via
ref(...) - Navigation behavior on non-anchor elements with
link(...) - Native click, pointer, and keyboard behavior with
on(...), plus attributes withattrs(...) - Element-level animation mixins from
remix/ui/animation
For richer animation work (springs, tweens, layout transitions), see animate-elements.md. For authoring custom mixins, see create-mixins.md. For component lifecycle and updates, see component-model.md.
Compose behavior on host elements with mix. Pass a single mixin directly (mix={on(...)}), or an array when composing multiple mixins (mix={[css(...), on(...)]}). Core mixins are imported from remix/ui; animation mixins are imported from remix/ui/animation.
on(type, handler, capture?)
Attaches a typed DOM event handler. The handler receives the event and an AbortSignal that aborts when the handler is re-entered or the component is removed — this prevents race conditions:
<input
mix={on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
if (signal.aborted) return
results = data.results
loading = false
handle.update()
})}
/>Multiple events on the same element:
<form
mix={on('submit', (event) => {
event.preventDefault()
let formData = new FormData(event.currentTarget)
})}
>css(styles)
Applies generated class names for CSS object styles. Produces static CSS rules inserted into the document. Supports pseudo-selectors, pseudo-elements, attribute selectors, descendant selectors, and media queries using & to reference the current element:
<button
mix={css({
color: 'white',
backgroundColor: 'blue',
padding: '12px 24px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
'&:hover': { backgroundColor: 'darkblue' },
'&:active': { transform: 'scale(0.98)' },
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
'& .title': { fontSize: '20px', fontWeight: 'bold' },
'@media (max-width: 768px)': { width: '100%' },
})}
/>css(...) vs style prop
Use css(...) for static styles, selectors, and media queries. Use style for dynamic values that change often. Prefer CSS nested selectors for parent-state-affects-children over managing hover/focus state in JavaScript:
<div
mix={css({
backgroundColor: 'blue', // static
'&:hover': { '& .title': { color: 'blue' } }, // parent hover → child
})}
style={{ width: `${progress}%` }} // dynamic
/>ref(callback)
Calls a callback when an element is inserted. The callback receives the DOM node and an AbortSignal that aborts when the element is removed:
<input mix={ref((node) => node.focus())} />
<div mix={ref((node, signal) => {
let observer = new ResizeObserver((entries) => {
dimensions.width = Math.round(entries[0].contentRect.width)
handle.update()
})
observer.observe(node)
signal.addEventListener('abort', () => observer.disconnect())
})} />The ref callback runs once when the element is first rendered, not on every update.
link(href, options?)
Adds client-side navigation behavior to any element. Makes non-anchor elements behave like Remix navigation links:
<article mix={link('/courses/intro')}>
<h3>Introduction</h3>
</article>Options match NavigationOptions: src, target, history ('push' | 'replace'), resetScroll.
Native press and keyboard interactions
Use native DOM events directly with on(...). For buttons and links, click already includes keyboard activation when the element has the right semantics:
<button mix={on('click', () => doAction())}>Action</button>For gesture-specific behavior, compose the pointer or keyboard events the interaction actually needs:
<button
mix={[
on('pointerdown', (event) => {
event.currentTarget.setPointerCapture(event.pointerId)
}),
on('pointerup', () => doAction()),
]}
>
Action
</button>
<div
tabIndex={0}
mix={on('keydown', (event) => {
if (event.key === 'Escape') close()
if (event.key === 'Enter' || event.key === ' ') doAction()
})}
/>attrs()
Sets HTML attributes through the mixin system.
Animation Mixins
animateEntrance(config)
Animates an element when it is inserted into the DOM. Config specifies the starting style:
<div mix={animateEntrance({ opacity: 0, transform: 'translateY(8px)', duration: 180 })} />animateExit(config)
Animates an element when it is removed. Config specifies the ending style. The element is kept in the DOM until the animation completes:
{
isVisible && (
<div
key="panel"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
]}
/>
)
}animateLayout(config?)
Animates layout changes (position/size) using FLIP-style transforms:
{
items.map((item) => (
<li key={item.id} mix={animateLayout({ duration: 220, easing: 'ease-out' })} />
))
}Options: duration (default 200ms), easing (default spring snappy), size (boolean, default true — include scale projection for size changes).
Always key elements you expect to animate. Use ...spring(preset) to spread duration and easing into any animation config.
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/:orderIdURL 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(Database),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(Database)
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(Database)
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(Database)
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(Database), 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(Database)
let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<IndexPage items={items} />)
},
async show({ get, params }) {
let db = get(Database)
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(Database), context.get(Session), context.get(Auth), etc.
Testing
What This Covers
How to test the two layers most Remix code lives in: HTTP behavior and DOM behavior. Read this when the task involves:
- Driving the router with
router.fetch(new Request(...))and asserting on the returnedResponse - Building a fresh router per test for session, storage, or database isolation
- Rendering components into a real DOM with
render(...)orcreateRoot(...) - Configuring
remix testdiscovery, excludes, and coverage - Using adjacent CLI checks such as
remix routes,remix doctor, andremix version - Choosing which layer to test for a given behavior
For session and auth test setup, see auth-and-sessions.md. For component lifecycle, see component-model.md.
Two Shapes
Remix tests run with remix test, use remix/test for the test framework, and use remix/assert for assertions. Two main shapes:
- Server / router tests — drive the router with
router.fetch(new Request(...))and assert on the returnedResponse. No DOM, no browser harness. - Component tests — render a component into a real DOM
Elementwithrender(...), or usecreateRoot(...)directly when you need lower-level root control.
Server / Router Tests
Treat the router as a pure (Request) => Promise<Response> function. Build a fresh app router per test (or per suite) so middleware state — sessions, in-memory storage, the database — stays isolated.
import * as assert from 'remix/assert'
import { describe, it } from 'remix/test'
import { createBookstoreRouter } from '../app/router.ts'
import { routes } from '../app/routes.ts'
describe('home', () => {
it('responds 200 with the home page', async () => {
let router = createBookstoreRouter()
let response = await router.fetch(new Request('http://localhost' + routes.home.href()))
assert.equal(response.status, 200)
assert.match(await response.text(), /Welcome to the Bookstore/)
})
})Use routes.<name>.href(...) to build URLs in tests so they stay in sync with the route definition. For form-style POSTs, attach a FormData body to the Request. For tests that need a known session, swap in createMemorySessionStorage() and a test cookie when constructing the router.
import { createMemorySessionStorage } from 'remix/session-storage/memory'
import { createCookie } from 'remix/cookie'
let router = createBookstoreRouter({
sessionCookie: createCookie('session', { secrets: ['test'] }),
sessionStorage: createMemorySessionStorage(),
})Use createTestServer from remix/node-fetch-server/test when the behavior depends on a real HTTP origin, redirects, streaming, cookies through a network boundary, or browser-style fetch:
import { createTestServer } from 'remix/node-fetch-server/test'
let server = await createTestServer((request) => router.fetch(request))
try {
let response = await fetch(new URL(routes.home.href(), server.baseUrl))
assert.equal(response.status, 200)
} finally {
await server.close()
}Test Runner Config
Configure discovery and coverage in remix-test.config.ts or with CLI flags:
export default {
glob: {
test: '**/*.test{,.e2e}.{ts,tsx}',
e2e: '**/*.test.e2e.{ts,tsx}',
exclude: 'node_modules/**',
},
coverage: {
dir: '.coverage',
include: ['app/**/*.{ts,tsx}'],
exclude: ['app/**/*.test.{ts,tsx}'],
statements: 80,
lines: 80,
branches: 70,
functions: 80,
},
}Use remix test --coverage to enable coverage with defaults. Use glob.exclude when discovery would otherwise enter generated output, symlinked workspaces, or other paths that should not produce tests.
Component Tests
Use render(...) from remix/ui/test for most component tests. It creates a real DOM container, flushes the initial render, and returns act(...) so interactions can flush pending updates before assertions. Use createRoot(container) from remix/ui directly when a test needs explicit control over root rendering, flushing, or disposal.
Basic pattern
import * as assert from 'remix/assert'
import { render } from 'remix/ui/test'
let result = render(<Counter />)
let button = result.$('button')!
await result.act(() => button.click())
assert.match(result.container.textContent ?? '', /1/)
result.cleanup()Why act / flush
- After initial render — ensures event listeners are attached and the DOM is ready for interaction.
- After interactions — applies updates from
handle.update()calls triggered by events. - After async work resolves — applies updates from resolved
queueTask(...)callbacks.
Async operations
For components with async operations in queueTask, use act(...) after each async step:
let result = render(<AsyncLoader />)
assert.equal(result.container.textContent, 'Loading...')
await waitForFetch()
await result.act(() => {})
assert.equal(result.container.textContent, 'Expected data')Component removal
Use result.cleanup() or root.dispose() to remove the component tree and verify cleanup behavior:
let result = render(<MyComponent />)
assert.ok(result.$('.content'))
result.cleanup()
assert.throws(() => result.$('.content'), /cleaned up/)Guidelines
- Prefer real DOM interactions over mocking framework behavior.
- Avoid testing implementation-only markers unless they are the only stable synchronization point.
- One representative flow proving a behavior is better than repeating the same assertion across many paths.