Build a Remix App
Use this skill for end-to-end Remix app work. It should help the agent choose the right layer first, reach for the right package, and avoid the most common Remix-specific mistakes.
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 and actions implement that contract and return
Responseobjects. - 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 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
- Classify the change. Decide whether it changes the route contract, request lifecycle, data model, auth or session behavior, or only UI.
- Start from the server contract. Add or update
app/routes.tsbefore wiring handlers or UI. - Put code in the narrowest owner. Favor route-local code first, then promote only when reuse is real.
- Make the server path correct before adding browser behavior. A route
should return the right
Responseviarouter.fetch(...)before you addclientEntry(...), animations, or DOM effects. - Add middleware deliberately. Keep fast-exit middleware early and
request-enriching middleware later. Export a typed
AppContextfrom the root middleware stack and use it in controllers. - Validate input at the boundary. Parse and validate
Request,FormData, params, cookies, and external payloads before they reach rendering or persistence logic. - Hydrate only when necessary. Prefer server-rendered UI. Use
clientEntry(...)andrun(...)only for real browser interactivity or browser-only APIs. - Test the narrowest meaningful layer. Prefer router tests for route behavior. Use component tests when the behavior is truly interactive or DOM-specific.
- 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 behaviorcontrollers/for route-owned handlers and route-local UIdata/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:
- Put it in the narrowest owner first.
- If it belongs to one route, keep it with that route.
- If it is shared UI across route areas, move it to
app/ui/. - If it is request lifecycle setup, keep it in
app/middleware/. - If it is schema, query, persistence, or startup data logic, keep it in
app/data/. - Use
app/utils/only as a last resort for truly cross-layer helpers.
Route Ownership
- Use a flat file in
app/controllers/for a simple leaf action, such asapp/controllers/home.tsx - Use a folder with
controller.tsxwhen a route owns nested routes or multiple actions, such asapp/controllers/account/controller.tsx - Mirror nested route structure on disk, such as
app/controllers/auth/login/controller.tsx - Keep route-local UI next to its owner, such as
app/controllers/contact/page.tsx - Move shared UI to
app/ui/ - If a flat leaf grows child routes or multiple actions, promote it to a controller folder
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 put shared cross-route UI in
app/controllers/ - Do not put middleware or persistence helpers in
app/utils/when they have a clearer home - Do not create folders for simple leaf actions unless they are real controllers
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 and actions 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 - 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 root 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: read props from
handle.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 - 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 - 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 - 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, controller and middleware types, and registering routes. A URL that matches a route pattern but not the request method gets405 Method Not Allowedwith anAllowheader (it does not reachdefaultHandler); register anANYroute for a per-URL catch-all.GETroutes also serveHEADwith the same status and headers and an empty body — do not add explicitHEADroutes just for thatremix/routes— declarative route builders. Use forroute,get,post,put,del,form,resourceswhen definingapp/routes.tsremix/node-fetch-server— adapter from Node'shttpmodule to a Fetch-style router. Use forcreateRequestListenerinserver.tsremix/assets— browser asset server. Use forcreateAssetServerwhen serving compiled scripts and styles. Scripts resolve imports through import maps: readgetScriptEntry()and render<ImportMap value={importMap} />(fromremix/ui/server) before the preloads and module script; usefingerprint: true(content hashes) plusfiles.cacheKeyfor persistent caches, and directorymounts(default{ app, npm }). Shared compiler options such astarget,sourceMaps,sourceMapSourcePaths, andminifylive at the top level. Kody's origin website does not use this: hydration URLs come from Pitlane?assets=remix/multiple-import-maps-polyfill—importModule(),detectMultipleImportMapSupport(),preloadShim()for browsers without native multiple-import-map support. Only relevant withremix/assetsremix/middleware/render— pairscreateAssetServerwith frame rendering for Node apps. Do not use it on the origin Worker; Kody ownsrenderToStreamplus Workers Assetsremix/spa— client-onlyrun(router)for apps with no server document. Kody keeps a custom client router on top of server-rendered HTML: it runs the destination's loader before committing the URL, and route components read the payload throughcreateRouteData(#client/route-data.tsx) so the previous page stays until the next one is ready — seedocs/contributing/no-flash-navigation.mdbefore adding a client routeremix/headers— typed header parsers and builders. Use when readingAccept,Cookie, or settingCacheControl,Vary, etc., instead of hand-formatting stringsremix/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 outside the globalcompression()middlewareremix/response/file— file-download responses. Use forContent-Disposition: attachmentresponsesremix/route-pattern— low-level URL matching and generation. Use when working with raw patterns outside the router (custom matchers, scripts)remix/fetch-proxy— Fetch-based HTTP proxying. Use to forward a request to another origin; passxForwardedHeaderswhen the upstream needs forwarded proto, host, and port
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-table— typed tables and aDatabaseinterface. Use fortable,column, andnew Database(driver)when modeling persisted dataremix/data-table/sqlite,remix/data-table/postgres,remix/data-table/mysql— official drivers. UsecreateSqliteDatabase(...)for Node, Bun, and compatible synchronous SQLite clients. Remix does not ship a D1 factory; Kody ownsD1DatabaseDriverand passes it tonew Databaseremix/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 logic
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 root 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, and OIDC providers. Use to define how identity is verified, start/finish external login, and refresh stored OAuth/OIDC token bundles withrefreshExternalAuth(...). UseOAuthTokens. There is no built-in Atmosphere/atproto provider.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 behavior. Framework attributes aredata-rmx-*(data-rmx-target,data-rmx-document,data-rmx-history, …). Listen with nativetarget.addEventListener(type, listener, { signal }); there is noaddEventListeners()helper. Server-rendered<script>elements must have a single string child (or stay empty withsrc); non-string children render empty and error.handle.update()during setup warns and is skipped — move it to an event handler orhandle.queueTask(). Optionalrun({ resolveFrame })defaults to fetching the frame source as HTML and rendering3xx/4xxHTML responses in the frame; Kody keeps a custom resolver for the frame registry, prefetch cache, and retries but mirrors that status acceptance.run()falls back to document navigation when the Navigation API is missing, so nowindow.navigationstub is needed.remix/ui/server— server rendering:renderToStream,renderToString. Use in therender(...)helper that returns HTML responsesremix/ui/animation— animation APIs:animateEntrance,animateExit,animateLayout,spring,tween, andeasingsremix/ui/<primitive>— UI primitives, mixins, glyphs, and theme helpers. Import fromremix/ui/accordion,remix/ui/button,remix/ui/select, etc.remix/ui/test— component test rendering helpers such asrenderremix/ui/jsx-runtime— JSX transform target. 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 globally 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/cli— programmatic Remix CLI API. Use theremixexecutable for project commands such asremix test,remix routes, andremix doctorremix/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 sequences
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 type { Controller } from 'remix/router'
import type { AppContext } from '../router.ts'
import { routes } from '../routes.ts'
export default {
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} />)
},
},
} satisfies Controller<typeof routes.books, AppContext>
Compose middleware deliberately
import {
createRouter,
type AnyParams,
type MiddlewareContext,
type WithParams,
} from 'remix/router'
export type RootMiddleware = [
ReturnType<typeof formData>,
ReturnType<typeof session>,
ReturnType<typeof loadDatabase>,
ReturnType<typeof loadAuth>,
]
export type AppContext<params extends AnyParams = AnyParams> = WithParams<
MiddlewareContext<RootMiddleware>,
params
>
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 })
Mutate, validate, and respond
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'
let bookSchema = f.object({
slug: f.field(s.string()),
title: f.field(s.string()),
})
export default {
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 }))
},
},
} satisfies Controller<typeof routes.books, AppContext>
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.