Skip to content

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():

ts
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:

ts
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:

ts
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:

ts
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:

ts
export const routes = route({
  users: {
    show: '/users/:id',
    posts: '/users/:userId/posts/:postId',
  },
})
PatternExample URLParams
/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:

ts
export const routes = route({
  assets: '/assets/*path',
  catchAll: '/*splat',
})
PatternExample URLParams
/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

ts
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:

ts
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():

ts
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:

ts
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:

ts
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:

ts
export const routes = route({
  settings: form('settings', { formMethod: 'PUT' }),
})

This creates:

  • routes.settings.index --- GET /settings
  • routes.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:

ts
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/edit

The resources() Helper

For full CRUD (Create, Read, Update, Delete) operations, the resources() helper generates all seven conventional routes at once:

ts
import { route, resources } from 'remix/fetch-router/routes'

export const routes = route({
  admin: route('admin', {
    books: resources('books', { param: 'bookId' }),
  }),
})

This creates:

Route NameMethodURL PatternPurpose
admin.books.indexGET/admin/booksList all books
admin.books.newGET/admin/books/newShow "new" form
admin.books.createPOST/admin/booksCreate a book
admin.books.showGET/admin/books/:bookIdShow one book
admin.books.editGET/admin/books/:bookId/editShow "edit" form
admin.books.updatePUT/admin/books/:bookIdUpdate a book
admin.books.destroyDELETE/admin/books/:bookIdDelete a book

The param Option

The param option controls the name of the dynamic segment. Without it, the default is :id:

ts
// Default: uses :id
resources('books')
// /admin/books/:id

// Custom: uses :bookId
resources('books', { param: 'bookId' })
// /admin/books/:bookId

The only Option

If you do not need all seven routes, use only to select specific ones:

ts
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:

ts
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'),
})
HelperHTTP MethodTypical Use
get()GETRead data, load pages
post()POSTCreate new resources
put()PUTUpdate existing resources
del()DELETERemove 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:

  1. Static segments beat dynamic segments: /users/new beats /users/:id
  2. Dynamic segments beat wildcards: /files/:name beats /files/*path
  3. Longer paths beat shorter paths: /a/b/c beats /a/b
  4. Routes with fewer dynamic segments beat those with more

This means you can safely define overlapping patterns without worrying about order:

ts
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:

ts
// 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:

ts
// 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:

tsx
// 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
// 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:

  1. The HTTP method ('GET', 'POST', 'PUT', 'DELETE')
  2. 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:

ts
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:

ts
// 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 params type matches the route's dynamic segments
  • No extra actions are defined that do not correspond to routes

Wiring Controllers to the Router

ts
// 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:

ts
// 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:

ts
// 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:

ts
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'),
  }),
})

Released under the MIT License.