Remix Routes Demystified
Around six months ago, Remix became open source. It brings a lovely developer experience and approximates web development to the web platform in a refreshing way. It’s a known tale that naming is the hardest thing in programming, but the team nailed this one. Remix drinks from the community experience, puts the platform and browser behavior in a front seat; sprinkles the learning the authors got from React-Router, Unpkg, and from teaching React. Like a remixed record, its content mixes the old needs to novel solutions in order to deliver a flawless experience.
Writing a Remix app is fun, it gets developers scratching their heads about, “How did Forms actually work before?”, “Can Cache really do that?”, and (my personal favorite), “The docs just pointed me to Mozilla Dev Network!”
In this article, we will dig deeper and look beyond the hype, though. Let’s pick inside (one of) Remix’s secret sauces and see one of the features that powers most of its features and fuels many of its conventions: routes. Buckle up!
Anatomy Of A Remix Repository
If pasting npx create-remix@latest
, following the prompt, and opening the scanning the bare bones project file-tree, a developer will be faced with a structure similar to the one bellow:
├───/.cache
├───/public
├───/app
│ ├───/routes
│ ├───entry.client.jsx
│ ├───entry.server.jsx
│ └───root.tsx
├───remix.config.js
└───package.json
.cache
will show up, once there’s a build output;public
is for static assets;app
is where the fun will happen, think of it as a/src
for now;- the files on the root are the configuration ones, for Remix, and for NPM.
Remix can be deployed to any JavaScript environment (even without Node.js). Depending on which platform you choose, the starter may output more files to make sure it all works as intended.
We have all seen similar repositories on other apps. But things already don’t feel the same with those entry.server.jsx
and entry.client.jsx
: every route has a client and a server runtime. Remix embraces the server-side from the very beginning, making it a truly isomorphic app.
While entry.client.jsx
is pretty much a regular DOM renderer with Remix built-in sauce, entry.server.jsx
already shows a powerful strategy of Remix routing. It is possible and clear to determine an app-wide configuration for headers, response, and metadata straight from there. The foundation for a multi-page app is set from the very beginning.
Routes Directory
Out of the 3 folders inside /app
on the snippet above, routes is definitely the most important. Remix brings a few conventions (one can opt-out with some configuration tweaks) that power the Developer Experience within the framework. The first convention, which has somewhat raised to a standard among such frameworks, is File System based routing. Within the /routes
directory, a regular file will create a new route from the root of your app. If one wants myapp.com
and myapp.com/about
, for example, the following structure can achieve it:
├───/apps
│ ├───/routes
│ │ ├───index.jsx
│ │ └───about.jsx
Inside those files, there are regular React components as the default export, while special Remix methods can be named exports to provide additional functionalities like data-fetching with the loader
and action
methods or route configuration like the headers
and meta
methods.
And here is where things start to get interesting: Remix doesn’t separate your data by runtime. There’s no “server data”, or “build-time data”. It has a loader for loading data, it has an action for mutations, headers and meta for extending/overriding response headers and metatags on a per-route basis.
Route Matching And Layouts
Composability is an order of business within the React ecosystem. A componentized program excels when we allow it to wrap one component on another, decorating them and empowering them with each other. With that in mind, the Layout Pattern has surfaced, it consists of creating a component to wrap a route (a.k.a another component) and decorate it in order to enforce UI consistency or make important data available.
Remix puts the Layout Pattern front and center it’s possible to determine a Layout to render all routes which match its name.
├───/apps
│ ├───/routes
│ │ ├───/posts // actual posts inside
│ │ └───posts.jsx // this is the layout
The posts.jsx
component uses a built-in Remix component (<Outlet />
) which will work in a similar way that React developers are used to have {children}
for. This component will use the content within a /posts/my-post.jsx
and render it within the layout. For example:
import { Outlet } from 'remix'
export default PostsLayout = () => (
<main>
<Navigation />
<article>
<Outlet />
</article>
<Footer />
</main>
)
But not always the UI will walk in sync with the URL structure. There is a chance that developers will need to create layouts without nesting routes. Take for example the /about
page and the /
, they are often completely different, and this convention ties down the URL structure with UI look and feel. Unless there is an escape hatch.
Skipping Inheritance
When nesting route components like above, they become child components of another component with the same name as their directory, like posts.jsx
is the parent component to everything inside /posts
through <Outlet />
. But eventually, it may be necessary to skip such inheritance while still having the URL segment. For example:
├───/apps
│ ├───/routes
│ │ ├───/posts // post
│ │ ├───posts.different-layout.jsx // post
│ │ └───posts.jsx // posts layout
In the example above, posts.different-layout.tsx
will be served in /posts/different-layout
, but it won’t be a child component of posts.jsx
layout.
Dynamic Routes
Creating routes for a complex multi-page app is almost impossible without some Dynamic Routing shenanigans. Of course, Remix has its covered. It is possible to declare the parameters by prefixing them with a $
in the file name, for example:
├───/apps
│ ├───/routes
│ | └───/users
│ │ └───$userId.jsx
Now, your page component for $userId.jsx
can look something like:
import { useParams } from 'remix'
export default function PostRoute() {
const { userId } = useParams()
return (
<ul>
<li>user: {userId}</li>
</ul>
)
}
Also there’s an additional twist: we can combine this with the Dot Limiters mentioned a few sections prior, and we can easily have:
├───/apps
│ ├───/routes
│ | └───/users
│ | ├───$userId.edit.jsx
│ │ └───$userId.jsx
Now the following path segment will not only be matched, but also carry out the parameter: /users/{{user-id}}/edit
. Needless to say, the same structure can be combined to also carry additional parameters, for example: $appRegion.$userId.jsx
will carry out the 2 parameters to your functions and page component: const { appRegion, userId } = useParams()
.
Catch-all With Splats
Eventually, developers may find themselves in situations where the number of parameters, or keys for each, a route is receiving is unclear. For these edge-cases Remix offers a way of catching everything. Splats will match everything which was not matched before by any of its siblings. For example, take this route structure:
├───/apps
│ ├───/routes
│ │ ├───about.jsx
│ │ ├───index.jsx
│ │ └───$.jsx // Splat Route
mydomain.com/about
will renderabout.jsx
;mydomain.com
will renderindex.jsx
;- anything that’s not the root nor
/about
will render$.jsx
.
And Remix will pass a params
object to both of its data handling methods (loader
and action
), and it has a useParams
hook (exactly the same from React-Router) to use such parameters straight on the client-side. So, our $.jsx
could look something like:
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>)
}
Check the Load data and the Mutating data sections for an in-depth explanation of loader
and action
methods respectively.
The params['']
will be a string
with the all params. For example: mydomain.com/this/is/my/route
will yield “this/is/my/route”. So, in this case we can just use .split('/')
to turn into an array like ['this', 'is', 'my', 'route']
.
Load Data
Each route is able to specify a method that will provide and handle its data on the server right before rendering. This method is the loader
function, it must return a serializable piece of data which can then be accessed on the main component via the special useLoaderData
hook from Remix.
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>
}
It’s important to point out, that the loader will always run on the server. Every logic there will not arrive in the client-side bundle, which means that any dependency used only there will not be sent to the user either. The loader function can run in 2 different scenarios:
- Hard navigation:
When the user navigates via the browser window (arrives directly to that page). - Client-side navigation:
When the user was in another page in your Remix app and navigates via a<Link />
component to this route.
When hard navigation happens, the loader method runs, provides the renderer with data, and the route is Server-Side Rendered to finally be sent to the user. On the client-side navigation, Remix fires a fetch
request by itself and uses the loader function as an API endpoint to fuel fresh data to this route.
Mutating Data
Remix carries multiple ways of firing a data mutation from the client-side: HTML form
tag, and extremely configurable <Form />
component, and the useFetcher
and useFetchers
hooks. Each of them has its own intended use-cases, and they are there to power the whole concept of an Optimistic UI that made Remix famous. We will park those concepts for now and address them in a future article because all these methods unfailingly communicate with a single server-side method: the action function.
Action
and Loader
are fundamentally the same method, the only thing which differentiates them is the trigger. Actions
will be triggered on any non-GET request and will run before the loader
is called by the re-rendering of the route. So, post a user interaction, the following cascade will happen on Remix’s side:
- Client-side triggers
Action
function, Action
function connects to the data source (database, API, …),- Re-render is triggered, calls Loader function,
Loader
function fetches data and feeds Remix rendering,- Response is sent back to the client.
Headers And Meta
As previously mentioned, there are other specific methods for each route that aren’t necessarily involved with fetching and handling data. They are responsible for your document headers and metatags.
Exporting meta
function allows the developer to override the metatag values defined in the root.jsx
and tailor it to that specific route. If a value isn’t changed, it will seamlessly inherit. The same logic will apply to the headers
function, but with a twist.
Data usually is what determines how long a page can be cached, so, naturally, the document inherits the headers from its data. If headers
function doesn’t explicitly declare otherwise, the loader
function headers will dictate the headers of your whole document, not only data. And once declared, the headers
function will receive both: the parent headers and the loader headers as parameters.
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
These routes are essentially one which doesn’t exist naturally in the website’s navigation pattern. Usually, a resource route does not return a React component. Besides this, they behave exactly the same as others: for GET
requests, the loader
function will run, for any other request method, the action
function will return the response.
Resource routes can be used in a number of use cases when you need to return a different file type: a pdf
or csv
document, a sitemap, or other. For example, here we are creating a PDF file and returning it as a resource to the user.
export const loader: LoaderFunction = async () => {
const pdf = somethingToPdf()
return new Response(pdf, {
headers: {
'Content-Disposition': 'attachment;',
'Content-Type': 'application/pdf',
},
})
}
Remix makes it straightforward to adjust the response headers, so we can even use Content-Disposition
to instruct the browser that this specific resource should be saved to the file system instead of displaying inline to the browser.
Remix Secret Sauce: Nested Routes
Here is where a multi-page app meets single-page apps. Since Remix’s routing is powered by React-Router, it brings its partial routing capabilities to the architecture. Each route is responsible for its own piece of logic and presentation, and this all can be declared used by the File-System heuristics again. Check this:
├───/apps
│ ├───/routes
│ │ ├───/dashboard
│ │ | ├───profile.jsx
│ │ | ├───settings.jsx
│ │ | └───posts.jsx
│ │ └───dashboard.jsx // Parent route
And just like we did implicitly on our Layout paradigm before, and how Remix handles the root
//routes
relationship, we will determine a parent route which will render all its children routes inside the <Outlet />
component. So, our dashboard.jsx
looks something like this:
import { Outlet } from 'remix'
export default function Dashboard () {
return (
<div>
some content that will show at every route
<Outlet />
</div>
)
}
This way, Remix can infer which resources to pre-fetch before the user asks for the page. because it allows the framework to identify relationships between each route and more intelligently infer what will be needed. Fetching all of your page’s data dependencies in parallel drastically boosts the performance of your app by eliminating those render and fetch waterfalls we dread so much seeing in (too) many web apps today.
So, thanks to Nested Routes, Remix is able to preload data for each URL segment, it knows what the app needs before it renders. On top of that, the only things that actually need re-rendering are the components inside the specific URL segment.
For example, take our above app , once users navigate to /dashboard/activity
and then to /dashboard/friends
the components it will render and data it will fetch are only the ones inside /friends
route. The components and resources matching the /dashboard
segment are already there.
So now Remix is preventing the browser from re-rendering the entire UI and only doing it for the sections that actually changed. It can also prefetch resources for the next page so once actual navigation occurs the transition is instant because data will be waiting at the browser cache. Remix is able to optimize it all out of the box with fine-grained precision, thanks to Nested Routes and powered by partial routing.
Wrapping Up
Routing is arguably the most important structure of a web app because it dictates the foundation where every component will relate to each other, and how the whole app will be able to scale going forward. Looking closely through Remix’s decisions for handling routes was a fun and refreshing ride, and this is only the scratch on the surface of what this framework has under its hood. If you want to dig deeper into more resources, be sure to check this amazing interactive guide for Remix routes by Dilum Sanjaya.
Though an extremely powerful feature and a backbone of Remix, we have just scratched the surface with routes and these examples. Remix shows its true potential on highly interactive apps, and it’s a very powerful set of features: data mutation with forms and special hooks, authentication and cookie management, and more.