Release Notes
Minor Changes
BREAKING CHANGE: Middleware must now explicitly continue the request chain by calling
next()or return aResponse. The router no longer callsnext()automatically when middleware returnsundefined; instead, it throws an error to catch missing continuation bugs early.Middleware that only mutates context should return the downstream response:
// Before function loadUser(): Middleware { return (context) => { context.set(CurrentUser, user) } } // After function loadUser(): Middleware { return (context, next) => { context.set(CurrentUser, user) return next() } }Middleware that needs to inspect or modify the downstream response should
await next()and return aResponse:function logger(): Middleware { return async (context, next) => { let response = await next() console.log(context.request.url, response.status) return response } }Add
router.mount()and theRouteBuilder/RouteInstallertypes so route groups can be written as local, reusable pieces of an app instead of hard-coding the full URL where they happen to live today. A route installer receives a prefixed route builder that can register routes with the sameroute(),map(), and method helpers as a router, while the parent router remains responsible for dispatch, matching, middleware, and default 404 handling.Before
router.mount(), route groups that lived in separate modules still needed to know where they were installed. That usually meant passing the root router around and repeating the parent path inside every route, which made feature code harder to move, reuse, or mount in more than one place:import { createRouter, type Router } from 'remix/router' function installAdminRoutes(router: Router<AppContext>) { router.get('/admin', () => new Response('Admin')) router.get('/admin/users/:id', ({ params }) => new Response(params.id)) } let router = createRouter<AppContext>({ middleware }) installAdminRoutes(router)Now the route group describes only its own local routes, and the parent decides where that group belongs:
import { createRouter, type RouteBuilder } from 'remix/router' function installAdminRoutes<context extends AppContext>(router: RouteBuilder<context>) { router.get('/', () => new Response('Admin')) router.get('/users/:id', ({ params }) => new Response(params.id)) } let router = createRouter<AppContext>({ middleware }) router.mount('/admin', installAdminRoutes) router.mount('/internal/admin', installAdminRoutes)Mount prefixes are route patterns, so params from the prefix are available in mounted handlers. This makes common nested app shapes like org-scoped settings, account dashboards, API versions, and admin sections type naturally without each child route repeating the scope prefix:
router.mount('/orgs/:orgId', (org) => { org.get('/users/:userId', ({ params }) => { return new Response(`${params.orgId}:${params.userId}`) }) })If a mount prefix and child route use the same param name, the right-most route param wins, matching
route-patternbehavior.Add
RouterContext<typeof router>for extracting the request context provided by a router or route builder. This lets apps keep root middleware inline and derive the app context from the router itself instead of storing a middleware tuple only so another type can refer to it:export const router = createRouter({ middleware: [loadSession(), loadDatabase()], }) type AppContext = RouterContext<typeof router> declare module 'remix/router' { interface RouterTypes { context: AppContext } }Make middleware context inference line up with the code people actually want to write.
createAction(), direct action objects registered withroute(), single-routemap(), or method helpers, andcreateController()now infer middleware-provided values from plain inline middleware arrays.Before this inference, stored actions and controllers that depended on middleware-provided values had to manually compose an intermediate context type:
let adminMiddleware = createMiddleware(requireAdmin()) type AdminContext = MiddlewareContext<typeof adminMiddleware, AppContext> let adminAction = createAction<typeof routes.admin, AdminContext>(routes.admin, { middleware: adminMiddleware, handler({ admin }) { return new Response(admin.id) }, })Now the inline middleware array on the action, route, or controller is enough for the handler to see the values it provides:
let adminAction = createAction(routes.admin, { middleware: [requireAdmin()], handler({ admin }) { return new Response(admin.id) }, }) router.get(routes.admin, { middleware: [requireAdmin()], handler({ admin }) { return new Response(admin.id) }, }) let adminController = createController(routes.admin, { middleware: [requireAdmin()], actions: { dashboard({ admin }) { return new Response(admin.id) }, }, })Add
createMiddleware()for the few cases where a reusable middleware chain must preserve its exact tuple type withoutas const. Prefer plain inline arrays formiddlewareoptions on routers, controllers, actions, and route helpers. UsecreateMiddleware()when a chain crosses a TypeScript inference boundary, such as derivingMiddlewareContext<typeof rootMiddleware>without a router value, exporting a reusable chain, or returning a chain from a factory.Middlewareis now modeled as a callable type alias with type-only context metadata instead of an interface call signature. Middleware provider APIs stay the same, but inline middleware arrays preserve their context transforms more reliably for action and controller handlers.The public router type surface is also smaller and easier to explain:
createRouter()androuter.map()each use a single call signature while preserving route params, middleware context inference, and stored action/controller compatibility checks.BREAKING CHANGE:
MapTargetandMapHandlerare no longer exported. These helper types existed to express the implementation ofrouter.map()and were not needed for application code. Use the publicRouter,RouteBuilder,RouteInstaller,Action, andControllertypes to describe router setup code.
Patch Changes
- Bumped
@remix-run/*dependencies: