Routes As The Backbone

Remix became open source about six months ago, and since then much of the conversation around it has centered on routing. That focus is justified: routes aren't just a way to organize URLs in Remix — they're the mechanism that drives data loading, mutations, layouts, and even response headers.

A fresh npx create-remix@latest scaffold reveals a structure that looks familiar at first glance, but the details signal a different philosophy:

├───/.cache
├───/public
├───/app
│   ├───/routes
│   ├───entry.client.jsx
│   ├───entry.server.jsx
│   └───root.tsx
├───remix.config.js
└───package.json

Alongside the expected public folder for static assets and app as the source directory, the presence of entry.server.jsx and entry.client.jsx makes the framework's stance clear from the start. Remix is isomorphic by design, with every route having both a client and a server runtime. The server entry file in particular establishes an app-wide point for configuring headers, responses, and metadata, laying the foundation for a multi-page application.

Conventions Over Configuration

Inside app, the routes directory is where the real work happens. Remix leans on file-system-based routing as its primary convention. A file placed directly in /routes maps to a URL at the app's root:

├───/apps
│   ├───/routes
│   │   ├───index.jsx
│   │   └───about.jsx

Each route file exports a React component as its default export. Named exports handle the rest: loader for data fetching, action for mutations, plus headers and meta for route-level response and metadata configuration. There's no separation of data by runtime — no "server data" vs. "build-time data" categories. One set of methods covers all cases.

Nested Layouts

The layout pattern — wrapping a component around another to enforce UI consistency or share data — is central to Remix. When a directory and a file share a name, the file becomes the layout for every route inside that directory. A posts.jsx file, for instance, wraps all routes under /posts:

├───/apps
│   ├───/routes
│   │   ├───/posts    // actual posts inside
│   │   └───posts.jsx // this is the layout

The layout renders its child route via the built-in <Outlet /> component, which plays the role that {children} does in regular React composition:

import { Outlet } from 'remix'

export default PostsLayout = () => (
  <main>
     <Navigation />
     <article>
       <Outlet />
     </article>
     <Footer />
  </main>
)

This tight coupling between URL structure and component hierarchy isn't always desirable, though. An /about page might share nothing visually with the home page it's nested under. For those cases, a file with a name like posts.different-layout.tsx will be served at /posts/different-layout without becoming a child of the posts.jsx layout:

├───/apps
│   ├───/routes
│   │   ├───/posts                       // post
│   │   ├───posts.different-layout.jsx   // post
│   │   └───posts.jsx                    // posts layout

The dot acts as a separator, letting developers keep URL segments while opting out of layout inheritance.

Dynamic Segments And Wild Cards

Dynamic routes use a $ prefix in the file name to declare parameters:

├───/apps
│   ├───/routes
│   |   └───/users
│   │         └───$userId.jsx

The useParams hook — the same one from React Router — exposes those values to the component:

import { useParams } from 'remix'

export default function PostRoute() {
  const { userId } = useParams()

  return (
    <ul>
      <li>user: {userId}</li>
    </ul>
  )
}

Multiple parameters can be chained into a single file name, and combined with the dot limiter for deeper nesting:

├───/apps
│   ├───/routes
│   |   └───/users
│   |         ├───$userId.edit.jsx
│   │         └───$userId.jsx

When the number of parameters is unpredictable, a splat route comes to the rescue. The file $.jsx catches everything not matched by its siblings:

├───/apps
│   ├───/routes
│   │   ├───about.jsx
│   │   ├───index.jsx
│   │   └───$.jsx      // Splat Route
  • mydomain.com/about renders about.jsx;
  • mydomain.com renders index.jsx;
  • anything else renders $.jsx.

The splat value arrives as a single string — mydomain.com/this/is/my/route yields "this/is/my/route" — so splitting on / turns it into a usable array:

import { useParams } from 'remix'
import type { LoaderFunction, ActionFunction } from 'remix'

export const loader: LoaderFunction = async ({
  params
}) => {
  return (params['*'] || '').split('/')
};

export const action: ActionFunction = async ({
  params
}) => {
  return (params['*'] || '').split('/')
};

export default function SplatRoute() {
  const params = useParams()
  console.log(return (params['*'] || '').split('/'))

  return (<div>Wow. Much dynamic!</div>)
}

Loading Data On The Server

Every route can export a loader function that runs on the server immediately before rendering. It returns a serializable payload that the component accesses through the useLoaderData hook:

import type { LoaderFunction } from 'remix'
import type { ProjectProps } from '~/types'
import { useLoaderData } from 'remix'

export const loader: LoaderFunction = async () => {
  const repositoriesResp = await fetch(
    'https://api.github.com/users/atilafassina/repos'
  )
  return repositoriesResp.json()
}

export default function Projects() {
  const repositoryList: ProjectProps[] = useLoaderData()

  return (<div>{repositoryList.length}</div>
}

A critical detail: the loader always executes on the server. Its logic and any dependencies it imports never reach the client bundle. This makes it a safe place for database credentials, API keys, or heavy computation.

Loaders fire in two scenarios. On hard navigation — when a user arrives directly at a URL — the loader runs, the route is server-side rendered, and the full HTML is sent. On client-side navigation via a <Link />, Remix issues a fetch request internally, treating the loader as an API endpoint to pull fresh data for that route.

Mutations And The Action Method

For data changes, Remix offers several client-side triggers: plain HTML form tags, the enhanced <Form /> component, and the useFetcher and useFetchers hooks — the latter being what powers optimistic UI updates. All of them funnel into a single server method: action.

The action and loader functions are structurally identical; only their trigger differs. Actions fire on any non-GET request and run before the loader during a route re-render. After a user interaction, the flow is:

  1. Client-side triggers the action function;
  2. action connects to the data source (database, API, etc.);
  3. The re-render fires, calling the loader;
  4. loader fetches data and feeds Remix's rendering pipeline;
  5. The response goes back to the client.

Per-Route Headers And Metadata

Beyond data methods, each route can export meta and headers functions to control the document's metadata and response headers. A meta export overrides values from root.jsx for that specific route, inheriting anything it doesn't explicitly change.

Headers work the same way, with one important nuance: because cache duration is typically determined by data, the document inherits cache headers from its loader. If headers isn't explicitly declared, the loader's headers govern the entire document. When headers IS declared, it receives both the parent headers and the loader headers as arguments:

import type { HeadersFunction } from 'remix'

export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => ({
  ...parentHeaders,
  ...loaderHeaders,
  "x-magazine": "smashing",
  "Cache-Control": "max-age: 60, stale-while-revalidate=3600",
})

Resource Routes: Handlers Without UI

A resource route is a route that doesn’t naturally fit into the site’s navigation structure and isn’t meant to render a React component. In every other way, it behaves like any other route: for GET requests, the loader runs; for other methods, the action returns the response.

This pattern is useful whenever you need to serve a non-HTML payload — a pdf, a csv, a sitemap, or a similar file. For instance, the following route creates a PDF and returns it as a downloadable resource:

export const loader: LoaderFunction = async () => {
  const pdf = somethingToPdf()

  return new Response(pdf, {
    headers: {
      'Content-Disposition': 'attachment;',
      'Content-Type': 'application/pdf',
    },
  })
}

Because Remix gives you direct control over response headers, you can also send a Content-Disposition header to tell the browser to save the file to disk instead of rendering it inline.

How Nested Routes Cut Render and Fetch Waterfalls

Remix’s routing sits on top of React-Router, which gives it partial routing capabilities. That means each route owns its own logic and presentation, and the file-system conventions let you declare that structure explicitly:

├───/apps
│   ├───/routes
│   │   ├───/dashboard
│   │   |    ├───profile.jsx
│   │   |    ├───settings.jsx
│   │   |    └───posts.jsx
│   │   └───dashboard.jsx      // Parent route

In the same way that the root route wraps everything under /routes, a parent route renders its children inside an <Outlet />. A dashboard.jsx file would look like this:

import { Outlet } from 'remix'

export default function Dashboard () {
  return (
   <div>
     some content that will show at every route
     <Outlet />
   </div>
  )
}

This structure lets Remix infer the relationship between routes and prefetch the right resources before the user even navigates. It can fetch all data dependencies for a page in parallel, which eliminates the render-and-fetch waterfalls common in many web apps and meaningfully improves performance.

With nested routes, Remix can preload data for each segment of the URL, so it knows what the app needs before rendering. When the user moves from /dashboard/activity to /dashboard/friends, only the /friends route’s components and data are fetched and rendered. The pieces belonging to /dashboard are already in place, so the browser skips re-rendering the whole UI — only the changed sections update.

That same mechanism enables prefetching for the next page, so transitions feel instant when the data is already waiting in the browser cache. All of this comes out of the box, with partial routing doing the fine-grained work under the hood.

Where Routing Fits in the Bigger Picture

Routing is the backbone of a web app; it defines how components relate to each other and determines how well the app scales. Remix’s approach to routes is refreshingly deliberate, and it’s only one layer of the framework. For an interactive walkthrough of the concepts, the demo by Dilum Sanjaya on Remix routes is worth a look.

Nested routes are powerful on their own, but they’re just the starting point. Remix really shines in highly interactive applications, where its data mutation with forms and hooks, authentication and cookie management, and similar features come together.

Smashing Editorial