Routing In Depth
Remix's routing system is the backbone of every application. It determines which code runs when a user visits a URL. This guide covers every aspect of routing in depth --- from basic pattern matching to advanced type-safe controllers.
If you are new to routing, start with the tutorial first. This guide assumes you understand the basics and want to go deeper.
How createRouter() Works
The router is the central object that receives HTTP requests and dispatches them to the correct handler. You create one with createRouter():
import { createRouter } from 'remix/fetch-router'
let router = createRouter()The router exposes a fetch method that takes a standard Fetch API Request and returns a Response:
let response = await router.fetch(request)This is the same interface as a Service Worker's fetch event, which means your Remix application can run anywhere the Fetch API is available --- Node.js, Bun, Deno, Cloudflare Workers, and more.
Global Middleware
You can pass an array of middleware functions that run on every request:
import { createRouter } from 'remix/fetch-router'
import { compression } from 'remix/compression-middleware'
import { logger } from 'remix/logger-middleware'
let router = createRouter({
middleware: [logger(), compression()],
})Middleware runs in the order you provide it. See the Middleware guide for details.
Route Patterns
Routes are defined using the route() helper from remix/fetch-router/routes. Every route has a name (the key) and a URL pattern (the value).
Static Segments
The simplest routes match exact URL paths:
import { route } from 'remix/fetch-router/routes'
export const routes = route({
home: '/',
about: '/about',
pricing: '/pricing',
terms: '/legal/terms-of-service',
})Static segments match literally. The route '/legal/terms-of-service' only matches the exact URL /legal/terms-of-service.
Dynamic Segments (:param)
A colon followed by a name creates a dynamic segment --- a placeholder that matches any single URL segment:
export const routes = route({
users: {
show: '/users/:id',
posts: '/users/:userId/posts/:postId',
},
})| Pattern | Example URL | Params |
|---|---|---|
/users/:id | /users/42 | { id: "42" } |
/users/:userId/posts/:postId | /users/5/posts/99 | { userId: "5", postId: "99" } |
Dynamic segments match a single path segment (everything between two slashes). They do not match slashes themselves, so /users/:id does not match /users/42/extra.
Params are always strings
Dynamic segment values are always strings, even if they look like numbers. /users/42 gives you params.id === "42" (a string), not 42 (a number). Convert explicitly if you need a number: Number(params.id).
Wildcard Segments (*name)
A wildcard segment matches everything after that point in the URL, including slashes:
export const routes = route({
assets: '/assets/*path',
catchAll: '/*splat',
})| Pattern | Example URL | Params |
|---|---|---|
/assets/*path | /assets/images/logo.png | { path: "images/logo.png" } |
/*splat | /anything/at/all | { splat: "anything/at/all" } |
Wildcards are useful for:
- Serving static files where paths can be deeply nested
- Building catch-all 404 pages
- Proxying requests to other services
The route() Helper
The route() function is the primary way to define routes. It accepts an object where keys are route names and values are URL patterns or nested objects.
Basic Usage
import { route } from 'remix/fetch-router/routes'
export const routes = route({
home: '/',
about: '/about',
contact: '/contact',
})Grouped Routes
When related routes share a logical grouping, nest them in an object:
export const routes = route({
books: {
index: '/books',
show: '/books/:slug',
genre: '/books/genre/:genre',
},
})You access grouped routes as routes.books.index, routes.books.show, etc. Grouping is purely organizational --- it does not affect URL patterns.
Nested Routes with a Prefix
When a group of routes shares a URL prefix, pass the prefix as the first argument to route():
export const routes = route({
admin: route('admin', {
index: '/', // GET /admin
settings: '/settings', // GET /admin/settings
users: {
index: '/users', // GET /admin/users
show: '/users/:id', // GET /admin/users/:id
},
}),
})The prefix is prepended to every URL in the group. '/' inside the group becomes /admin, and '/users/:id' becomes /admin/users/:id.
You can nest route() calls as deeply as needed:
export const routes = route({
api: route('api', {
v1: route('v1', {
users: '/users', // GET /api/v1/users
posts: '/posts', // GET /api/v1/posts
}),
v2: route('v2', {
users: '/users', // GET /api/v2/users
}),
}),
})The form() Helper
The form() helper creates a pair of routes for pages that both display and process a form:
import { route, form } from 'remix/fetch-router/routes'
export const routes = route({
contact: form('contact'),
})This creates two routes:
routes.contact.index---GET /contact(display the form)routes.contact.action---POST /contact(process the submission)
Custom Form Methods
By default, the action route uses POST. You can change this with the formMethod option:
export const routes = route({
settings: form('settings', { formMethod: 'PUT' }),
})This creates:
routes.settings.index---GET /settingsroutes.settings.action---PUT /settings
This is useful for update forms where PUT is semantically more appropriate than POST.
Form with Dynamic Segments
form() supports dynamic segments in the path:
export const routes = route({
admin: route('admin', {
editBook: form('books/:bookId/edit'),
}),
})
// routes.admin.editBook.index -> GET /admin/books/:bookId/edit
// routes.admin.editBook.action -> POST /admin/books/:bookId/editThe resources() Helper
For full CRUD (Create, Read, Update, Delete) operations, the resources() helper generates all seven conventional routes at once:
import { route, resources } from 'remix/fetch-router/routes'
export const routes = route({
admin: route('admin', {
books: resources('books', { param: 'bookId' }),
}),
})This creates:
| Route Name | Method | URL Pattern | Purpose |
|---|---|---|---|
admin.books.index | GET | /admin/books | List all books |
admin.books.new | GET | /admin/books/new | Show "new" form |
admin.books.create | POST | /admin/books | Create a book |
admin.books.show | GET | /admin/books/:bookId | Show one book |
admin.books.edit | GET | /admin/books/:bookId/edit | Show "edit" form |
admin.books.update | PUT | /admin/books/:bookId | Update a book |
admin.books.destroy | DELETE | /admin/books/:bookId | Delete a book |
The param Option
The param option controls the name of the dynamic segment. Without it, the default is :id:
// Default: uses :id
resources('books')
// /admin/books/:id
// Custom: uses :bookId
resources('books', { param: 'bookId' })
// /admin/books/:bookIdThe only Option
If you do not need all seven routes, use only to select specific ones:
resources('orders', {
only: ['index', 'show'],
param: 'orderId',
})
// Only creates:
// GET /orders (index)
// GET /orders/:orderId (show)Valid values for only are: 'index', 'new', 'create', 'show', 'edit', 'update', 'destroy'.
HTTP Method Helpers
For routes that only respond to a specific HTTP method, use the method-specific helpers:
import { get, post, put, del } from 'remix/fetch-router/routes'
export const routes = route({
search: get('/search'),
logout: post('/logout'),
updateProfile: put('/profile'),
deleteAccount: del('/account'),
})| Helper | HTTP Method | Typical Use |
|---|---|---|
get() | GET | Read data, load pages |
post() | POST | Create new resources |
put() | PUT | Update existing resources |
del() | DELETE | Remove resources |
A plain string like '/about' is equivalent to get('/about') --- it only responds to GET requests.
del() not delete()
The delete helper is named del() because delete is a reserved keyword in JavaScript. Using delete as a function name would cause a syntax error.
Route Specificity and Matching Order
When multiple routes could match a URL, Remix uses specificity rules to pick the most appropriate one. More specific routes take priority over less specific ones.
The rules, in order of priority:
- Static segments beat dynamic segments:
/users/newbeats/users/:id - Dynamic segments beat wildcards:
/files/:namebeats/files/*path - Longer paths beat shorter paths:
/a/b/cbeats/a/b - Routes with fewer dynamic segments beat those with more
This means you can safely define overlapping patterns without worrying about order:
export const routes = route({
users: {
new: '/users/new', // Matches /users/new
show: '/users/:id', // Matches /users/42, /users/alice, etc.
catchAll: '/users/*rest', // Matches /users/42/posts/1 and anything else
},
})A request to /users/new matches the new route (static segment wins), not the show route (dynamic segment).
No manual ordering needed
Unlike some frameworks where you must define routes in a specific order, Remix routes are matched by specificity, not by the order they appear in your code. You can define them in whatever order makes your code most readable.
Type-Safe URL Generation with href()
Every route has an href() method that generates the correct URL string. TypeScript enforces that you pass the correct parameters:
// Static routes --- no parameters needed
routes.home.href() // "/"
routes.about.href() // "/about"
// Dynamic routes --- parameters required
routes.users.show.href({ id: '42' }) // "/users/42"
// TypeScript error: Property 'id' is missing
routes.users.show.href({}) // Error!
// TypeScript error: 'name' does not exist on type
routes.users.show.href({ name: 'x' }) // Error!Query Parameters
You can append query parameters using a second argument or by appending them manually:
// Build URL with query string
let url = routes.search.href() + '?q=remix'
// "/search?q=remix"Using href() in Templates and JSX
Use href() everywhere you need a URL --- links, form actions, redirects:
// In JSX
<a href={routes.books.show.href({ slug: book.slug })}>
{book.title}
</a>
// In form actions
<form method="POST" action={routes.auth.logout.href()}>
<button type="submit">Log Out</button>
</form>
// In redirects
return new Response(null, {
status: 302,
headers: { Location: routes.home.href() },
})Refactoring safety
If you rename a route or change a URL pattern, TypeScript flags every href() call that needs updating. This eliminates broken links caused by manual URL strings.
Mapping Routes to Handlers with router.map()
Defining routes only creates URL patterns. To handle requests, you connect routes to handlers using router.map():
Single Route
For a single route, pass an object with a handler function:
router.map(routes.home, {
handler() {
return new Response('Hello, world!')
},
})The handler function receives a request context with access to the request, URL, params, and middleware context:
router.map(routes.users.show, {
handler({ request, url, params, get }) {
let userId = params.id
// ... look up user, return response
},
})Route Group (Controller Pattern)
For a group of routes, pass an object with an actions property. Each key in actions corresponds to a route name in the group:
router.map(routes.books, {
actions: {
index() {
return new Response('All books')
},
show({ params }) {
return new Response(`Book: ${params.slug}`)
},
genre({ params }) {
return new Response(`Genre: ${params.genre}`)
},
},
})Per-Route Middleware
You can attach middleware to specific routes or route groups:
import { requireAuth } from './middleware/auth.ts'
router.map(routes.admin, {
middleware: [requireAuth],
actions: {
index() { /* ... */ },
// All actions in this group require authentication
},
})See the Middleware guide for more on per-route middleware.
The Controller Pattern
In a real application, you typically extract handlers into separate controller files. A controller is an object that contains handler functions for a route or group of routes.
Single Route Controllers with BuildAction
For a single route, use the BuildAction type:
// app/controllers/home.tsx
import type { BuildAction } from 'remix/fetch-router'
import type { routes } from '../routes.ts'
export const home: BuildAction<'GET', typeof routes.home> = {
handler() {
return new Response('Home page')
},
}The BuildAction type takes two arguments:
- The HTTP method (
'GET','POST','PUT','DELETE') - The route type (
typeof routes.home)
TypeScript verifies that the handler signature matches the route. If the route has dynamic segments, params is correctly typed:
export const showUser: BuildAction<'GET', typeof routes.users.show> = {
handler({ params }) {
// TypeScript knows params.id exists and is a string
let userId = params.id
return new Response(`User ${userId}`)
},
}Route Group Controllers with Controller
For route groups, use the Controller type:
// app/controllers/books.tsx
import type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'
export default {
actions: {
index() {
return new Response('All books')
},
show({ params }) {
// TypeScript knows params.slug exists
return new Response(`Book: ${params.slug}`)
},
genre({ params }) {
// TypeScript knows params.genre exists
return new Response(`Genre: ${params.genre}`)
},
},
} satisfies Controller<typeof routes.books>The satisfies Controller<typeof routes.books> check ensures:
- Every route in the group has a corresponding action
- Each action's
paramstype matches the route's dynamic segments - No extra actions are defined that do not correspond to routes
Wiring Controllers to the Router
// app/router.ts
import { createRouter } from 'remix/fetch-router'
import { routes } from './routes.ts'
import { home } from './controllers/home.tsx'
import booksController from './controllers/books.tsx'
let router = createRouter()
router.map(routes.home, home)
router.map(routes.books, booksController)Route Params Type Inference
Remix automatically infers the params type from the route pattern. You never need to manually define param types:
// Route: '/users/:userId/posts/:postId'
handler({ params }) {
params.userId // string (TypeScript infers this)
params.postId // string (TypeScript infers this)
params.name // Error: Property 'name' does not exist
}For wildcard routes:
// Route: '/files/*path'
handler({ params }) {
params.path // string (TypeScript infers this)
}This inference flows through BuildAction, Controller, href(), and everywhere else route types are used.
Complete Example
Here is a full routing setup for a typical application:
import { route, form, resources, get, post, put, del } from 'remix/fetch-router/routes'
export const routes = route({
// Static assets
assets: '/assets/*path',
// Public pages
home: '/',
about: '/about',
contact: form('contact'),
search: get('/search'),
// Content
blog: {
index: '/blog',
show: '/blog/:slug',
tag: '/blog/tag/:tag',
},
// Auth
auth: {
login: form('login'),
register: form('register'),
logout: post('/logout'),
forgotPassword: form('forgot-password'),
resetPassword: form('reset-password/:token'),
},
// Authenticated user area
account: route('account', {
index: '/',
settings: form('settings', { formMethod: 'PUT' }),
orders: resources('orders', {
only: ['index', 'show'],
param: 'orderId',
}),
}),
// Admin area
admin: route('admin', {
index: get('/'),
posts: resources('posts', { param: 'postId' }),
users: resources('users', {
only: ['index', 'show', 'edit', 'update', 'destroy'],
param: 'userId',
}),
}),
// API
api: route('api', {
health: get('/health'),
webhooks: post('/webhooks/:provider'),
}),
})Related
- Tutorial: Routing --- Learn the basics step by step.
- Middleware --- Add cross-cutting behavior to routes.
- Request & Response --- Understand the objects handlers work with.
- Forms & Mutations --- Handle form submissions with
form()routes.