Skip to content

fetch-router

The fetch-router package is the heart of every Remix application. It creates a router that maps incoming HTTP requests to handler functions using the standard Fetch API Request and Response types.

Installation

bash
# Via the meta-package
npm install remix

# Or individually
npm install @remix-run/fetch-router

Imports

ts
// Router, context, and middleware
import {
  createRouter,
  createContextKey,
  RequestContext,
} from 'remix/fetch-router'

// Types
import type {
  Router,
  RouterOptions,
  Middleware,
  NextFunction,
  Controller,
  BuildAction,
  RequestHandler,
  RequestMethod,
} from 'remix/fetch-router'

// Route helpers
import {
  route,
  form,
  resources,
  resource,
  get,
  post,
  put,
  patch,
  del,
  head,
  options,
} from 'remix/fetch-router/routes'

// Route helper types
import type {
  Route,
  RouteMap,
  FormOptions,
  ResourcesOptions,
  ResourcesMethod,
} from 'remix/fetch-router/routes'

createRouter(options?)

Creates a new router instance.

ts
import { createRouter } from 'remix/fetch-router'

let router = createRouter()

RouterOptions

OptionTypeDefaultDescription
middlewareMiddleware[]undefinedGlobal middleware that runs on every request.
defaultHandlerRequestHandler404 responseHandler invoked when no route matches.
matcherMatcher<MatchData>ArrayMatcherThe pattern matcher implementation.
ts
import { createRouter } from 'remix/fetch-router'
import { logger } from 'remix/logger-middleware'
import { compression } from 'remix/compression-middleware'

let router = createRouter({
  middleware: [logger(), compression()],
  defaultHandler({ url }) {
    return new Response(`Nothing at ${url.pathname}`, { status: 404 })
  },
})

When middleware is provided as a const tuple, the router's context type automatically reflects the context contributions of each middleware.


Router

The router object returned by createRouter().

router.fetch(input, init?)

Dispatches a request through the router and returns a response. This is the primary entry point --- it has the same signature as the global fetch function.

ts
let response = await router.fetch(request)
ParameterTypeDescription
inputstring | URL | RequestThe request to handle.
initRequestInitOptional request init options.
ReturnsPromise<Response>The response from the matched handler.

router.get(target, handler)

Registers a handler for GET requests.

ts
router.get('/about', () => {
  return new Response('About page')
})

router.post(target, handler)

Registers a handler for POST requests.

ts
router.post('/users', ({ request }) => {
  // Create a user
  return new Response('Created', { status: 201 })
})

router.put(target, handler) / router.patch(target, handler) / router.delete(target, handler)

Register handlers for PUT, PATCH, and DELETE requests respectively. Same signature as router.get().

router.head(target, handler) / router.options(target, handler)

Register handlers for HEAD and OPTIONS requests respectively.

router.route(method, target, handler)

Registers a handler for a specific HTTP method and route target. This is the general form that the verb methods delegate to.

ts
router.route('GET', '/health', () => {
  return new Response('OK')
})

router.map(target, handler)

The most versatile registration method. It accepts either a single route with an action, or a route map with a controller.

Single route:

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

let routes = route({
  home: '/',
})

router.map(routes.home, {
  handler() {
    return new Response('Home')
  },
})

Route map (controller):

ts
let routes = route({
  books: {
    index: '/books',
    show: '/books/:slug',
  },
})

router.map(routes.books, {
  actions: {
    index() {
      return new Response('All books')
    },
    show({ params }) {
      return new Response(`Book: ${params.slug}`)
    },
  },
})

With per-route middleware:

ts
router.map(routes.admin, {
  middleware: [requireAuth],
  actions: {
    index() { /* ... */ },
    settings({ params }) { /* ... */ },
  },
})

RequestContext

Every request handler receives a RequestContext instance. It carries the request, parsed URL, route params, and a type-safe key-value store for middleware data.

Properties

PropertyTypeDescription
requestRequestThe original Fetch API Request.
urlURLThe parsed URL of the request.
paramsRecord<string, string>Route parameters extracted from the URL. Typed to the specific route pattern.
methodRequestMethodThe HTTP method. May differ from request.method when using method-override middleware.
headersHeadersA copy of the request headers.
routerRouterThe router handling this request.

context.get(key)

Retrieves a value from the context's type-safe key-value store.

ts
let user = context.get(UserKey)

Throws if the key has no default value and has not been set.

context.set(key, value)

Sets a value in the context's key-value store.

ts
context.set(UserKey, user)

context.has(key)

Returns true if a value has been set for the given key.

ts
if (context.has(UserKey)) {
  // ...
}

createContextKey<T>(defaultValue?)

Creates a type-safe key for storing and retrieving values from RequestContext. This is how middleware passes data to downstream handlers.

ts
import { createContextKey } from 'remix/fetch-router'

interface User {
  id: string
  name: string
}

let UserKey = createContextKey<User>()

With a default value:

ts
let ThemeKey = createContextKey<'light' | 'dark'>('light')

When a default value is provided, context.get(ThemeKey) returns the default instead of throwing when no value has been set.


Middleware

A middleware function that can intercept requests, modify context, and optionally short-circuit the handler chain by returning a Response.

ts
import type { Middleware, NextFunction } from 'remix/fetch-router'
import { createContextKey, RequestContext } from 'remix/fetch-router'

let TimingKey = createContextKey<number>()

let timing: Middleware = async (context, next) => {
  let start = Date.now()
  let response = await next()
  let duration = Date.now() - start
  response.headers.set('X-Response-Time', `${duration}ms`)
  return response
}

Signature

ts
interface Middleware<method, params, transform> {
  (context: RequestContext, next: NextFunction):
    | Response
    | undefined
    | void
    | Promise<Response | undefined | void>
}
ParameterTypeDescription
contextRequestContextThe request context.
nextNextFunctionCall to invoke the next middleware or handler. Returns Promise<Response>.
ReturnsResponse | void | Promise<...>Return a Response to short-circuit. Return nothing (or call next()) to continue.

If a middleware neither returns a Response nor calls next(), the framework automatically calls next() on its behalf.

Context Transforms

Middleware can declare a type-level context transform that tells TypeScript what context entries it adds:

ts
import type { Middleware, ContextEntry } from 'remix/fetch-router'

type AuthTransform = [ContextEntry<typeof UserKey, User>]

let auth: Middleware<'ANY', {}, AuthTransform> = async (context, next) => {
  let user = await loadUser(context.request)
  context.set(UserKey, user)
  return next()
}

Downstream handlers then have context.get(UserKey) correctly typed as User.


RequestHandler

The function signature for route handlers.

ts
interface RequestHandler<params, context> {
  (context: context): Response | Promise<Response>
}

Controller

A type for objects that mirror a route map with matching action handlers. Used with router.map() for route groups.

ts
import type { Controller } from 'remix/fetch-router'

let booksController = {
  actions: {
    index() {
      return new Response('All books')
    },
    show({ params }) {
      return new Response(`Book: ${params.slug}`)
    },
  },
} 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.

A controller can include middleware that applies to all actions in the group:

ts
let adminController = {
  middleware: [requireAuth],
  actions: {
    index() { /* ... */ },
    users({ params }) { /* ... */ },
  },
} satisfies Controller<typeof routes.admin>

BuildAction

A type helper for building single-route action types from a route or pattern.

ts
import type { BuildAction } from 'remix/fetch-router'

// From a Route object
type HomeAction = BuildAction<'GET', typeof routes.home>

// From a string pattern
type UserAction = BuildAction<'GET', '/users/:id'>

Use it to type controller files:

ts
export const showUser: BuildAction<'GET', typeof routes.users.show> = {
  handler({ params }) {
    // params.id is typed as string
    return new Response(`User ${params.id}`)
  },
}

Route Helpers

Route helpers are imported from remix/fetch-router/routes. They define URL patterns that the router matches against.

route(definitions) / route(prefix, definitions)

Creates a route map from an object. Keys are route names, values are URL patterns or nested objects.

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

let routes = route({
  home: '/',
  about: '/about',
  books: {
    index: '/books',
    show: '/books/:slug',
  },
})

With a URL prefix:

ts
let routes = route({
  admin: route('admin', {
    index: '/',          // GET /admin
    users: '/users',     // GET /admin/users
  }),
})

Nesting can go as deep as needed:

ts
let routes = route({
  api: route('api', {
    v1: route('v1', {
      users: '/users',   // GET /api/v1/users
    }),
  }),
})

form(name, options?)

Creates paired GET/POST routes for pages that display and process a form.

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

let routes = route({
  contact: form('contact'),
})

// routes.contact.index  -> GET  /contact
// routes.contact.action -> POST /contact

FormOptions

OptionTypeDefaultDescription
formMethodstring'POST'The HTTP method for the action route.
names{ index?: string, action?: string }{ index: 'index', action: 'action' }Custom names for the generated routes.
ts
let routes = route({
  settings: form('settings', { formMethod: 'PUT' }),
})
// routes.settings.index  -> GET /settings
// routes.settings.action -> PUT /settings

resources(name, options?)

Generates all seven conventional CRUD routes.

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

let routes = route({
  books: resources('books', { param: 'bookId' }),
})
RouteMethodURL Pattern
books.indexGET/books
books.newGET/books/new
books.createPOST/books
books.showGET/books/:bookId
books.editGET/books/:bookId/edit
books.updatePUT/books/:bookId
books.destroyDELETE/books/:bookId

ResourcesOptions

OptionTypeDefaultDescription
paramstring'id'The name of the dynamic segment.
onlyResourcesMethod[]allLimits which routes are created. Valid values: 'index', 'new', 'create', 'show', 'edit', 'update', 'destroy'.
excludeResourcesMethod[][]Excludes specific routes from the generated set.
namesRecord<ResourcesMethod, string>default namesCustom names for the generated routes.
ts
resources('orders', {
  only: ['index', 'show'],
  param: 'orderId',
})
// Only creates GET /orders and GET /orders/:orderId

get(pattern) / post(pattern) / put(pattern) / del(pattern) / patch(pattern) / head(pattern) / options(pattern)

Creates a route that only responds to a specific HTTP method.

ts
import { get, post, put, del } from 'remix/fetch-router/routes'

let routes = route({
  search: get('/search'),
  logout: post('/logout'),
  updateProfile: put('/profile'),
  deleteAccount: del('/account'),
})

A plain string like '/about' is equivalent to get('/about').

del() not delete()

The delete helper is named del() because delete is a reserved keyword in JavaScript.

resource(name, options?)

Creates routes for a singular resource (no collection routes). Similar to resources() but without index, new, or create.


Route

A Route object represents a single named route with a method and a pattern. Route objects are created by the route helpers and stored in route maps.

route.href(...args)

Generates a URL string from the route's pattern. TypeScript enforces that you pass the correct parameters.

ts
routes.home.href()                     // "/"
routes.books.show.href({ slug: 'dune' }) // "/books/dune"

For routes with dynamic segments, the params argument is required:

ts
// TypeScript error: Property 'slug' is missing
routes.books.show.href({})  // Error!

route.pattern

The underlying RoutePattern instance.

route.method

The HTTP method this route responds to ('GET', 'POST', etc., or 'ANY').


Complete Example

ts
// app/routes.ts
import { route, form, resources, get, post } from 'remix/fetch-router/routes'

export const routes = route({
  home: '/',
  about: '/about',
  contact: form('contact'),
  search: get('/search'),

  blog: {
    index: '/blog',
    show: '/blog/:slug',
    tag: '/blog/tag/:tag',
  },

  auth: {
    login: form('login'),
    logout: post('/logout'),
  },

  admin: route('admin', {
    index: '/',
    posts: resources('posts', { param: 'postId' }),
  }),
})
ts
// app/server.ts
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { createRequestListener } from 'remix/node-fetch-server'
import { logger } from 'remix/logger-middleware'
import { routes } from './routes.ts'
import blogController from './controllers/blog.ts'

let router = createRouter({
  middleware: [logger()],
})

router.get(routes.home, () => {
  return new Response('Welcome to Remix')
})

router.map(routes.blog, blogController)

let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000)
ts
// app/controllers/blog.ts
import type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'

export default {
  actions: {
    index() {
      return new Response('All posts')
    },
    show({ params }) {
      return new Response(`Post: ${params.slug}`)
    },
    tag({ params }) {
      return new Response(`Tag: ${params.tag}`)
    },
  },
} satisfies Controller<typeof routes.blog>

Released under the MIT License.