Skip to content

2. Routing

In Part 1 you created a project with a single route for the home page. A real bookstore needs many more pages: a book listing, individual book pages, a contact form, and more. In this part, you will learn how to define all of these using Remix's routing system.

By the end of this section, your application will have a complete set of URL patterns for the entire bookstore, and you will understand every tool Remix provides for defining routes.

What is a Route?

A route is a rule that connects a URL pattern to the code that should run when someone visits that URL. For example:

URL PatternExample URLWhat it shows
/http://localhost:3000/The home page
/abouthttp://localhost:3000/aboutThe about page
/bookshttp://localhost:3000/booksThe book listing
/books/:slughttp://localhost:3000/books/great-gatsbyA single book's details

The last pattern, /books/:slug, is special --- the :slug part is a dynamic segment, a placeholder that matches any value. More on that soon.

The route() Helper

Remix defines routes as plain data using the route() helper. Open app/routes.ts and replace its contents with:

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

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

  // Public book routes
  books: {
    index: '/books',
    genre: '/books/genre/:genre',
    show: '/books/:slug',
  },
})

Let's examine each part.

Simple String Routes

The simplest way to define a route is a string:

ts
home: '/',
about: '/about',
search: '/search',

Each key (like home, about, search) is the route name --- a label you use to refer to this route in your code. The value is the URL pattern that the route matches. When someone visits /about, the router will look for a handler mapped to routes.about.

A simple string route matches GET requests by default. That means it handles normal page visits (clicking a link, typing a URL in the address bar).

Dynamic Segments

Some URLs contain values that change. A book's page might be /books/great-gatsby or /books/dune --- the book's slug varies. You represent this with a dynamic segment, written as a colon followed by a name:

ts
books: {
  show: '/books/:slug',
  genre: '/books/genre/:genre',
},
  • :slug matches any value in that position. Visiting /books/great-gatsby sets slug to "great-gatsby". Visiting /books/dune sets slug to "dune".
  • :genre works the same way. Visiting /books/genre/fiction sets genre to "fiction".

When your handler runs, it receives these values in a params object:

ts
// If someone visits /books/great-gatsby
async handler({ params }) {
  console.log(params.slug) // "great-gatsby"
}

Dynamic segments are how you build pages that show different content based on the URL.

Grouped Routes

Notice that books is not a string --- it is an object containing multiple routes:

ts
books: {
  index: '/books',
  genre: '/books/genre/:genre',
  show: '/books/:slug',
},

This groups related routes together under a common name. You access them as routes.books.index, routes.books.genre, and routes.books.show. Grouping is purely for organization --- it does not affect the URL patterns themselves.

The form() Helper

Many pages need both a way to display a form and a way to process the form submission. The form() helper creates two routes in one:

ts
contact: form('contact'),

This is equivalent to writing:

ts
contact: {
  index: get('/contact'),       // GET /contact  - shows the form
  action: post('/contact'),     // POST /contact - handles the submission
},

When someone clicks a link to /contact, the browser sends a GET request, which shows the contact form. When they fill out the form and click "Submit", the browser sends a POST request to the same URL, which processes the data.

GET vs. POST

GET and POST are HTTP methods --- they tell the server what the browser wants to do. A GET request asks to retrieve something (like loading a page). A POST request asks to submit something (like sending form data). Browsers send GET requests when you click a link or type a URL, and POST requests when you submit a form with method="POST".

The get() and post() Helpers

Sometimes you need a route that only responds to a specific HTTP method. The get() and post() helpers do this:

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

// Only responds to GET requests
search: get('/search'),

// Only responds to POST requests
logout: post('/logout'),

There are also put() and del() helpers for PUT and DELETE requests, which are used for updating and deleting data:

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

// PUT /api/update - update something
update: put('/api/update'),

// DELETE /api/remove - delete something
remove: del('/api/remove'),

HTTP Methods

HTTP defines several methods (also called verbs) that describe the intended action:

  • GET --- retrieve data (load a page)
  • POST --- submit new data (create something)
  • PUT --- replace or update existing data
  • DELETE --- remove data

Browsers natively only send GET and POST, so Remix includes a methodOverride middleware that lets HTML forms simulate PUT and DELETE requests.

Nested Routes with route()

When a group of routes shares a URL prefix, you can use route() with a prefix to avoid repeating it:

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

export const routes = route({
  // ... other routes ...

  cart: route('cart', {
    index: get('/'),           // GET /cart
    api: {
      add: post('/api/add'),   // POST /cart/api/add
      update: put('/api/update'), // PUT /cart/api/update
      remove: del('/api/remove'), // DELETE /cart/api/remove
    },
  }),
})

The route('cart', { ... }) call says "prefix every URL in this group with /cart." So get('/') becomes GET /cart, and post('/api/add') becomes POST /cart/api/add.

This keeps your route definitions DRY (Don't Repeat Yourself) and makes the structure of your URLs easy to see at a glance.

The resources() Helper

For admin panels and APIs, you often need a full set of CRUD routes (Create, Read, Update, Delete) for a resource. The resources() helper generates all of them:

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

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

This single line creates seven routes:

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

The param option controls the name of the dynamic segment (:bookId instead of the default :id).

If you do not need all seven routes, use the only option to pick the ones you want:

ts
orders: resources('orders', {
  only: ['index', 'show'],  // Only list and detail views
  param: 'orderId',
}),

This creates just GET /admin/orders and GET /admin/orders/:orderId.

What is CRUD?

CRUD stands for Create, Read, Update, Delete --- the four basic operations for managing data. Almost every web application needs these operations for its core resources (books, users, orders, etc.). The resources() helper creates all the routes you need for CRUD in one line.

The Complete Bookstore Routes

Now let's define all the routes for the bookstore. Replace app/routes.ts with the full version:

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

export const routes = route({
  assets: '/assets/*path',
  uploads: '/uploads/*key',

  // Simple static routes
  home: '/',
  about: '/about',
  contact: form('contact'),
  search: '/search',

  // Public book routes
  books: {
    index: '/books',
    genre: '/books/genre/:genre',
    show: '/books/:slug',
  },

  // Auth routes
  auth: {
    login: form('login'),
    register: form('register'),
    logout: post('logout'),
    forgotPassword: form('forgot-password'),
    resetPassword: form('reset-password/:token'),
  },

  // Account section (requires login)
  account: route('account', {
    index: '/',
    settings: form('settings', {
      formMethod: 'PUT',
    }),
    orders: resources('orders', {
      only: ['index', 'show'],
      param: 'orderId',
    }),
  }),

  // Cart and shopping
  cart: route('cart', {
    index: get('/'),
    api: {
      add: post('/api/add'),
      update: put('/api/update'),
      remove: del('/api/remove'),
    },
  }),

  // Checkout flow
  checkout: route('checkout', {
    index: get('/'),
    action: post('/'),
    confirmation: get('/:orderId/confirmation'),
  }),

  // Admin section (requires admin role)
  admin: route('admin', {
    index: get('/'),
    books: resources('books', { param: 'bookId' }),
    users: resources('users', {
      only: ['index', 'show', 'edit', 'update', 'destroy'],
      param: 'userId',
    }),
    orders: resources('orders', {
      only: ['index', 'show'],
      param: 'orderId',
    }),
  }),
})

A few patterns worth highlighting:

Wildcard Segments

ts
assets: '/assets/*path',
uploads: '/uploads/*key',

A wildcard segment (*path, *key) matches everything after that point in the URL, including slashes. For example, /assets/images/logo.png would set path to "images/logo.png". This is useful for serving files where the path can be deeply nested.

Form Routes with Custom Methods

ts
settings: form('settings', {
  formMethod: 'PUT',
}),

By default, form() creates a GET route for displaying the form and a POST route for handling the submission. The formMethod option changes the submission method --- here, the settings form submits with PUT instead of POST, since updating your settings is an update operation, not a creation.

Type-Safe URL Generation with href()

One of Remix's best features is that you never need to manually construct URLs. Every route has an href() method that generates the correct URL:

ts
// Static routes
routes.home.href()              // "/"
routes.about.href()             // "/about"
routes.books.index.href()       // "/books"

// Dynamic routes - pass the parameter values
routes.books.show.href({ slug: 'great-gatsby' })
// "/books/great-gatsby"

routes.books.genre.href({ genre: 'fiction' })
// "/books/genre/fiction"

// Form routes
routes.contact.index.href()     // "/contact"
routes.auth.login.index.href()  // "/login"

For routes with dynamic segments, href() requires you to pass an object with the values for each segment. TypeScript enforces this at compile time --- if you forget a parameter or misspell one, you get an error in your editor before you even run the code.

This is much safer than writing URLs as strings. If you rename a route or change a URL pattern, TypeScript will flag every place in your code that needs updating. No broken links.

Using href() in HTML

You will use href() extensively when building pages. Here is a preview (you will learn more about JSX in Part 3):

tsx
<a href={routes.books.show.href({ slug: book.slug })}>
  View Details
</a>

<a href={routes.books.genre.href({ genre: 'fiction' })}>
  Fiction
</a>

<form method="POST" action={routes.auth.logout.href()}>
  <button type="submit">Log Out</button>
</form>

Mapping Routes to Handlers

Defining routes only sets up the URL patterns. To actually handle requests, you use router.map() to connect routes to handlers. Update app/router.ts:

ts
import { createRouter } from 'remix/fetch-router'
import { compression } from 'remix/compression-middleware'
import { logger } from 'remix/logger-middleware'
import { staticFiles } from 'remix/static-middleware'

import { routes } from './routes.ts'

let middleware = []

if (process.env.NODE_ENV === 'development') {
  middleware.push(logger())
}

middleware.push(compression())
middleware.push(staticFiles('./public'))

export let router = createRouter({ middleware })

// Map the home route to a handler
router.map(routes.home, {
  handler() {
    return new Response(
      `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bookstore</title>
  </head>
  <body>
    <h1>Welcome to the Bookstore</h1>
    <p>Your bookstore is up and running.</p>
    <nav>
      <a href="${routes.about.href()}">About</a> |
      <a href="${routes.books.index.href()}">Books</a> |
      <a href="${routes.contact.index.href()}">Contact</a>
    </nav>
  </body>
</html>`,
      { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
    )
  },
})

// Map the about route
router.map(routes.about, {
  handler() {
    return new Response(
      `<!DOCTYPE html>
<html lang="en">
  <head><title>About - Bookstore</title></head>
  <body>
    <h1>About the Bookstore</h1>
    <p>We sell books. Good ones.</p>
    <a href="${routes.home.href()}">Back to Home</a>
  </body>
</html>`,
      { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
    )
  },
})

// Map the books index route
router.map(routes.books, {
  actions: {
    index() {
      return new Response(
        `<!DOCTYPE html>
<html lang="en">
  <head><title>Books - Bookstore</title></head>
  <body>
    <h1>All Books</h1>
    <p>Book listing coming soon...</p>
    <a href="${routes.home.href()}">Back to Home</a>
  </body>
</html>`,
        { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
      )
    },

    genre({ params }) {
      return new Response(
        `<!DOCTYPE html>
<html lang="en">
  <head><title>${params.genre} Books - Bookstore</title></head>
  <body>
    <h1>${params.genre} Books</h1>
    <p>Books in the ${params.genre} genre coming soon...</p>
    <a href="${routes.books.index.href()}">All Books</a>
  </body>
</html>`,
        { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
      )
    },

    show({ params }) {
      return new Response(
        `<!DOCTYPE html>
<html lang="en">
  <head><title>Book: ${params.slug} - Bookstore</title></head>
  <body>
    <h1>Book: ${params.slug}</h1>
    <p>Book details coming soon...</p>
    <a href="${routes.books.index.href()}">All Books</a>
  </body>
</html>`,
        { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
      )
    },
  },
})

// Map the contact form routes
router.map(routes.contact, {
  actions: {
    index() {
      return new Response(
        `<!DOCTYPE html>
<html lang="en">
  <head><title>Contact - Bookstore</title></head>
  <body>
    <h1>Contact Us</h1>
    <form method="POST">
      <label>Name: <input type="text" name="name"></label><br>
      <label>Message: <textarea name="message"></textarea></label><br>
      <button type="submit">Send</button>
    </form>
    <a href="${routes.home.href()}">Back to Home</a>
  </body>
</html>`,
        { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
      )
    },

    action() {
      return new Response(
        `<!DOCTYPE html>
<html lang="en">
  <head><title>Thanks - Bookstore</title></head>
  <body>
    <h1>Thank You!</h1>
    <p>We received your message.</p>
    <a href="${routes.home.href()}">Back to Home</a>
  </body>
</html>`,
        { headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
      )
    },
  },
})

Single Route vs. Route Group

For a single route like routes.home, you pass a handler directly:

ts
router.map(routes.home, {
  handler() {
    return new Response(/* ... */)
  },
})

For a group of routes like routes.books (which contains index, genre, and show), you pass an object with an actions property. Each key in actions matches a route name in the group:

ts
router.map(routes.books, {
  actions: {
    index() { /* handles GET /books */ },
    genre({ params }) { /* handles GET /books/genre/:genre */ },
    show({ params }) { /* handles GET /books/:slug */ },
  },
})

Accessing Parameters

When a handler matches a route with dynamic segments, the segment values are available in params:

ts
genre({ params }) {
  // For URL /books/genre/fiction:
  params.genre  // "fiction"
}

show({ params }) {
  // For URL /books/great-gatsby:
  params.slug  // "great-gatsby"
}

Try It Out

Start your dev server (npm run dev) and visit these URLs in your browser:

Try submitting the contact form --- you should see the "Thank You!" page.

Missing routes will 404

If you visit a URL that does not match any defined route (like /nonexistent), the router returns a 404 Not Found response. This is correct behavior --- it means only the URLs you explicitly define are valid.

Recap

In this part you learned:

  • Routes map URL patterns to handler code.
  • route() defines routes as a structured object.
  • Dynamic segments (:slug, :genre) capture variable parts of the URL.
  • form() creates paired GET/POST routes for form pages.
  • get(), post(), put(), del() create method-specific routes.
  • route('prefix', { ... }) nests routes under a shared URL prefix.
  • resources() generates a full set of CRUD routes.
  • href() generates type-safe URLs --- no more hardcoded strings.
  • router.map() connects routes to handlers.

The HTML strings in our handlers are getting long and repetitive. In the next part, you will learn how to use JSX and components to build pages cleanly and reuse common elements like the page layout.

Continue to Part 3: Building Pages -->

Released under the MIT License.