Remix guidance

September 14, 2026 · View on GitHub

Use the repo-local Remix skill instead of vendoring generated Remix package documentation in this repository.

The repo-local skill lives at:

  • .agents/skills/remix/SKILL.md

Load that skill before changing Remix routes, controllers, middleware, data access, validation, auth, sessions, file uploads, server setup, UI components, hydration, navigation, frames, or tests.

Kody D1 leftovers (unused columns, dual-write, a later drop) follow Cleanup after migrations, not the Remix skill.

For <Frame> partial reloads, see frames.

npx remix@next new <app> copies this skill from the Remix CLI bootstrap template. The CLI does not expose a standalone remix skills install command.

Kody runtime topology

The Remix UI ships on the origin Cloudflare Worker (kody / kody-production), not as the Node HTTP server generated by remix new. The origin request path is:

packages/worker/src/index.ts -> packages/worker/src/app/handler.ts -> packages/worker/src/app/router.ts

Package-app hosts (kody.run) and the invocation API live on kody-runtime. Platform Durable Objects and scheduled jobs live on kody-platform and kody-jobs. See architecture.

Package apps are framework-agnostic Worker entries on kody-runtime (dynamic Worker Loader isolate), not on the origin. Remix is a recipe: the package's app/router.ts default-exports a fetch handler that remounts into createRouter(), the platform supplies remix at the same version as this repo (pre-bundled by tools/build-worker-bundler-modules.ts, mounted at node_modules/remix/ in the runtime bundler), and kody:runtime exposes Kody as named exports plus the optional KodyRuntime request-context key. Authoring conventions for that surface live in docs/guides/package-apps.md; the platform side is in packages and manifests. The skill below applies to origin UI and to the Remix package-app recipe, with two package-app differences: no Vite (esbuild via the runtime bundler, no HMR) and no remix/assets (the browser entry is one platform-built module under /_assets, hydrated through a loadModule registry).

Keep these Worker-specific differences when comparing Kody with the default template:

  • Wrangler supplies canonical Fetch API Request objects. Kody does not use remix/node-fetch-server, so its Node-only trustProxy option does not apply.
  • Production ships four product scripts via GitHub Actions. npm run deploy uploads origin only; there is no long-running npm start process.
  • Development uses Vite (@pitlane/dev + @cloudflare/vite-plugin) so origin SSR runs in workerd with HMR. Production client and origin worker assets come from vite build. Platform, runtime, jobs, and highlight stay auxiliary workers in vite dev and separate Wrangler deploys in production.
  • Static files are served through the Workers Assets binding rather than remix/assets or remix/middleware/static. Hydration uses clientEntry(import.meta.url, …) and Pitlane ?assets= imports. SSR renderToStream must pass resolveClientEntry so the serialized entry metadata (<script id="rmx-data">) points at the Vite hashed entry (/assets/entry-*.js), not the deleted /client-entry.js. Vite resolves imports itself, so no importMap is returned and the Remix import-map polyfill is not used.
  • Remix run() falls back to full document navigation when the browser lacks the Navigation API; do not add a window.navigation stub. crypto.randomUUID and constructable stylesheets are still polyfilled in packages/worker/client/entry.tsx for older in-app browsers.
  • Frame resolution is configured in both packages/worker/client/entry.tsx and packages/worker/src/app/ssr-render.tsx. The browser resolver is (src, options) and returns the Response. SSR resolveFrame is (src, target, context).
  • The app router uses Remix COP (remix/middleware/cop) before the account write lease. MCP, OAuth, package apps, and connectors never see that stack because index.ts handles them first. COP bypasses Stripe webhooks, package webhook ingress, and /sentry-tunnel.
  • App D1 access goes through D1DatabaseDriver (new Database(driver)). Remix does not ship a D1 factory.
  • Session and signed cookies import createCookie from remix/cookie.
  • Usernames are DNS labels for {username}. package-app hosts, so they cannot contain dots. Path params that may contain dots (secret names) must go through href() so . is encoded as %2E.

If a Node server entry point is added later, evaluate trustProxy only at that trusted reverse-proxy boundary. Do not copy it into Worker request handling.

Adding a page

For a typical authenticated HTML page, start with the route contract and wire the narrowest owners. The core route-wiring path is five files:

  1. packages/worker/universal/routes.ts — add the route identity. Treat routes.<name> as the canonical source of the pathname.
  2. packages/worker/src/app/router.ts — map the route to its handler in router.map(...).
  3. packages/worker/src/app/handlers/<page>.ts — implement the page handler. Use the helpers in #app/page-auth.ts (requireAuthenticatedPageUser, requirePageSession, requirePageUserWithRole) for page auth, and use #app/request-body.ts helpers when the page reads structured request bodies.
  4. packages/worker/client/routes/<page>.tsx — add the route component and its route loader, and read the payload through createRouteData from #client/route-data.tsx so navigations replace the previous page in one commit with no loading state (see no-flash navigation). Import shared loader payload types from #universal/loader-data.ts (kody-custom/prefer-loader-data-types enforces this). Keep UI-only state types local.
  5. packages/worker/client/routes/index.tsx — register the component in clientRoutes and any loader in clientRouteLoaders, keyed by routePattern(routes.<name>) instead of a duplicated literal pathname.

That covers the server contract, page handler, client route, and client registries in five edits. Treat packages/worker/universal/document-head.ts as the 5-vs-6 check: the count stays at five when the page can reuse existing head behavior, and it becomes six when the new pathname needs its own title, canonical URL, or Open Graph metadata. A brand-new standalone route commonly needs that sixth edit because unmatched pathnames fall back to the Not found title.

OAuth authorize/callback shells are the exception to the routes.ts rule. Those pathnames are owned by the Cloudflare OAuth provider wrapper, so client registries and document head use #universal/oauth-paths.ts (oauthPaths.authorize and oauthPaths.callback) instead of routes.ts.