v0.19.0 Release Notes

Holy smokes this is a big release with tons of good stuff. Some let you handle new use-cases, some clean up your code, and others automatically make your website better and you don't have to do anything. This release puts us within inches of a stable v1.

The biggest piece of work in this release is the rewrite of client side transitions. This enabled us to add a handful of new features, fix some bugs, and make it more efficient for the browser and faster for user at the same time.

When the URL changes Remix does a bunch of communication with the server. We used to have a 300 line useEffect that just kind of did everything. We lovingly referred to it as "the big effect". We knew it was incomplete, but we were waiting to see how the rest of Remix shook out before really tackling this work. The time came and we spent months getting it right. Most of the features in this release are from that work or built on top of it.

tl;dr Upgrade Guide and Breaking Changes

  • Upgrade to [email protected]
  • useRouteData -> useLoaderData
  • usePendingFormSubmit -> useTransition().submission
  • usePendingLocation -> useTransition().location
  • block({ rel: "preload", as: "image", href }) -> Remove the block call, can render a <link rel="prefetch"> wherever you link to the page
  • links({ data }) -> Use <Link prefetch="intent"> for { page } links you used with data and then inline <link /> inside your component based on the useLoaderData instead. Most uses of <link> are "body ok", so you can just render them inside the component instead.
  • Returning a string from actions for a redirect need to actually return redirect(string)

React Router v6.0.0-beta.6

Remix is now compatible with React Router v6.0.0-beta.6. We're days away from launching the stable v6 release over there! You must upgrade your react router dependency for Remix to continue to work properly.

Changes to actions

Actions don't require you to redirect out of them anymore! You can return responses just like loaders now. The data you return is available from useActionData(). This is especially nice for server side form validation errors: just return the errors as an object, no more session/action/loader dance!

import { useActionData, json } from "remix";

export function action({ request }) {
  let body = new URLSearchParams(await request.text());
  let name = body.get("visitorsName");
  return json({ message: `Hello, ${name}` });

export default function Invoices() {
  let data = useActionData();
  return (
    <Form method="post">
          What is your name?
          <input type="text" name="visitorsName" />
      <p>{data ? data.message : "Waiting..."}</p>;

Note about resubmissions: Remix previously required redirects from actions to prevent accidental resubmissions (like booking a flight twice if the user clicks back). If you're rendering <Scripts/> the form will not be resubmitted on back or refresh so you're still protected automatically. However, now that you aren't required to redirect out of actions, Remix can't protect your users from resubmissions when you aren't rendering <Scripts/>. If you are handling forms without JavaScript, we highly recommend you still redirect out of your actions or ensure your actions can be run mutliple times without negative consequences.

Finally, since actions can return data, returning a string will no longer automatically redirect, it will send down the string as data. You'll need to wrap it in redirect(string) when upgrading.

Read more about useActionData

useLoaderData replaces useRouteData()

Because "route data" can come from both loaders and actions now, useRouteData didn't make a lot of sense so we've got two hooks now:

useLoaderData(); // data from your loader
useActionData(); // data from your action

useTransition replaces usePendingLocation and usePendingFormSubmit

With the transition rewrite, we've got a better hook that ecompasses all "pending" information. This hook tells you everything you need to know to build even better loading experiences. For example, you can indicate all phases of the pending form submission to the user. Previous we only knew it was pending and nothing more, now you know everything.

function SubmitButton() {
  let transition = useTransition();
  let text =
    transition.type === "actionSubmission"
      ? "Creating Record"
      : transition.type === "actionRedirect"
      ? "Redirecting to new record..."
      : "Create";
  return <button type="submit">{text}</button>;

Updating from the old hooks is pretty straightforward:

// old
// new

// old
// new

This hook also sets a solid foundation for us to finish our in-progress automatic scroll restoration, which should come very soon after this release.

There are numerous improvements to client side transitions that don't affect your code, but make your app better. In the case of interrupted navigations and form submissions, Remix previously simply ignored the responses of stale navigation fetches. Now it automatically aborts them using AbortController, saving your user's network bandwidth and the browser doesn't waste CPU cycles processing the response.

Read more about useTransition

Same URL data reloading and hash changes

Without JavaScript, if users click a link to the page they are already on, the browser will request a brand new document but replace the current entry in the history stack. Remix now emulates that behavior by refetching all loaders on the page and replacing the current entry in the history stack.

We also fixed a bug where loaders were called when only the url hash was changing. URL hashes don't go to the server so they no longer cause loaders to be called either, but they are a new location.


While Remix's loaders and actions are great for traditional navigations, modern apps often require more dynamic ways to communicate with the server. This hook enables you to call your loaders and actions outside of a navigation. You might think of it as using your loaders and actions as "API routes". Here are a few examples:

  • Writing a loader that returns data for a <Combobox> auto suggest component
  • A newsletter sign up form at the bottom of multiple pages in your app
  • Any UI where you need to allow multiple actions to be pending at the same time (like a list of records with single click buttons to change their state on the server)
  • Components that fetch data based on user interactions rather than navigation, like a user avatar that pops up their profile when hovered or focused.

Here's an example of marking an article as read:

function useMarkAsRead({ articleId, userId }) {
  let markAsRead = useFetcher();

  useSpentSomeTimeHereAndScrolledToTheBottom(() => {
      { userId },
        method: "POST",
        action: `/article/${articleID}/mark-as-read`,

After the action completes, Remix will do its normal thing of reloading all loaders on the page after actions to ensure the data shown to the user is the latest data from the server. If multiple actions are pending at the same time, Remix makes sure to commit every fresh respnose and aborts any stale ones. That's right, Remix automatically takes care of race conditions!

Additionally, if you return a redirect from a loader/action being called by a fetcher, Remix will redirect the application to that page. And if any errors are thrown, the nearest error boundary will be rendered as usual. With useFetcher you get all of the same protections as a normal navigation when communicating with the server.

There are a lot more examples in the docs you should go check out:

Read more about useFetcher


During client side transitions, Remix will optimize reloading of routes that are already rendering, like not reloading layout routes that aren't changing. In other cases, like form submissions or search param changes, Remix doesn't know which routes need to be reloaded so it reloads them all to be safe. This ensures data mutations from the submission or changes in the search params are reflected across the entire page.

This function lets apps further optimize by returning false when Remix is about to reload a route. The most common case is telling Remix to never reload the root route:

export let loader = () => {
  return {
    ENV: {

export let unstable_shouldReload = () => false;

As always, Remix puts you in charge of the network tab.

Read more about shouldReload

This feature is awesome. One of our goals with Remix is to "destroy all spinners". Of course, we have a really great API to help you build great loading UI (useTransition), but the end goal is to not need the spinner in the first place. You can do that by link prefetching:

import { Link, NavLink } from "remix" // not react router!

// prefetch resources when the user seems like they're going to click it
<Link prefetch="intent" />

// prefetch it when this link renders
<NavLink prefetch="render" />

We recommend covering your app with <Link prefetch="intent" />. Because of nested routes, Remix is able to prefetch, in parallel:

  • The JS modules for next matching routes in the link
  • The CSS from the links() export of those routes
  • All the loader data for the next routes

Under the hood it uses <link rel="prefetch"> so browsers can do everything they should (rather than other solutions that use their own framework level fetching and caching). For example, cache headers on your loaders will automatically be respected by the browser and chrome even prefetches these resources for the back and forward buttons after you click away from the page 🤯. Users can even refresh the browser or change tabs and the browser cache for any prefetched resources will still be available. What was that hash tag from a few years ago? #useThePlatform.

Before this release, the only way to prefetch a page was to include it in the links() export of a route. Link preloading makes the { page } link less interesting and there's a chance we'll remove it from the Remix v1.

But while it's still with us, it now prefetches CSS resources for the linked page!

In order for <Link prefetch> to be able to prefetch the CSS of the next page, we had to remove the data argument to links. We don't actually fetch the data, we tell the browser to do it with <link rel="prefetch" as="fetch" href={loaderURL}/>, so we don't actually have the data to be able to pass to links() when prefetching. Because of this we had a choice:

  1. Not be able to prefetch css and making it impossible to eliminate spinners on transitions to routes with links
  2. Remove the data arg and be able to prefetch all resources for the next page ahead of time, and in parallel

The main reason we provided the data arg in the first place was to prefetch pages based on data with { page } links. You can now do exactly that with <Link prefetch> so we feel comfortable removing this feature.

If you were using data for more than { page }, like { rel: "preload" } you will probably be able to do the prefetching of those resources on the page that links to the route that used to have the link preloads.

// old - routes/users/$userId.js
export function links({ data }) {
  return data.map((user) => ({
    rel: "preload",
    as: "image",
    href: user.avatarUrl,

// new - note this is not in the $userId.js route, it's wherever you're linking
// to the $userId route
export default function SomeComp() {
  let users = useLoaderData();
  return (
      {users.map((user) => (
        <Link to={user.id} prefetch="intent">
          {/* Prefetch it where you linked to it,
              you probably have the data you need */}

We recognize this is a bit of a bummer, but we couldn't eliminate spinners, and fetch more resources in parallel, without removing the data arg to links and we're confident you can still prefetch those resources in another way.

This feature allowed you to block the transition to a route on any linked resource in that route. The primary motivation was blocking on critical images to avoid content layout shift when you got there. Unfortunately, this only worked for client side transitions so users still experienced content layout shift on the initial page load of a page--which is actually the most important time to avoid CLS.

Since block could only solve half the problem, and blocking on images is generally a bad idea anyway (there's a reason browsers don't block on images for the initial load) we removed it to encourage developers to solve the root of the problem: put a height and width on your images :)

Any other resources you'd want to block on are already handled by Remix: JS modules, data, and CSS resources, and with link prefetching we'd like to block on as little as possible.

Catch Boundaries and useCatch()

In addition to returning responses from loaders and actions, you can now throw responses and like thrown errors, Remix will change its rendering path from the route component to the CatchBoundary. Check it out:

import { useCatch, json } from "remix";

export async function loader({ request, params }) {
  let userId = await requireUserSession(request);
  let project = await fakeDb.project.find({
    where: { id: params.id },

  // if at any point you can't render this route because you don't have the
  // right data, you can throw a response, code stops executing and Remix takes
  // the app down the "Catch Boundary" path.
  if (project === null) {
    throw new Response("", { status: 404 });

  // you can even include data in the response to tell the user how to fix the
  // problem
  if (!project.members.includes(userId)) {
    throw json(
      { ownerEmail: project.ownerEmail },
      { status: 401 }

  // but if everything is good, continue on the happy path!
  return json(project);

export function CatchBoundary() {
  let caught = useCatch();

  if (caught.status === 404) {
    return <div>Project not found.</div>;

  if (caught.status === 401) {
    return (
          You don't have access to this project. Email{" "}
          {caught.data.ownerEmail} to request access.

export function Project() {
  // you know everything worked on the server, no need to handle not found, no
  // access, etc. in your component. This is the happy path that Remix only
  // sends you down if everything worked on the server.
  let project = useLoaderData();
  return <ProjectView project={project} />;

When you throw a response from a loader, it bubbles just like error boundaries bubble, so any loader in your app can throw a 404 and it will bubble up to the nearest CatchBoundary. This means you can have granular 404 handling without taking out all of the UI on the page, as well as global handling by setting up a CatchBoundary at the top of your app.Any loader can throw a 404 and your root catch boundary will handle it if nobody else does in-between.

We recommend you copy/paste/tweak this into your src/root.js file (note that this is the <html> root of your app, so if you don't have a <Document> component like our remix init templates, make sure to include the entire html page you need):

// in src/root.js
export function CatchBoundary() {
  let caught = useCatch();

  switch (caught.status) {
    // add whichever other status codes you want to handle
    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses
    case 401:
    case 404:
      return (
          title={`${caught.status} ${caught.statusText}`}
            {caught.status} {caught.statusText}

      throw new Error(
        `Unexpected caught response with status: ${caught.status}`

Removal of routes/404.js

Because we now have catch boundaries, all 404 handling is done with that. You can move the code from your 404.js file into the root.js CatchBoundary component.

Throwing Redirects

Along with catch boundaries introducing the ability to throw responses, any thrown redirect response will redirect the app. This is huge for writing cleaner code in your loaders and abstractions. For example, instead of the "pyramid of death" that was required before (with a callback "push" API), you can now write cleaner loaders and actions that throw instead of returning internally.

For example, consider the requireUserSession case.

// OLD
// src/utils/session.js
async function requireUserSession(request, cb) {
  let cookie = request.headers.get("Cookie");
  let session = await getSession(cookie);
  let auth = session.get("auth");

  if (!auth) {
    return redirect("/login");

  return cb(auth);

// src/routes/some-route.js
export async function loader({ request }) {
  // loaders had to return the call to this function so it could internally
  // return a redirect instead of calling our callback and returning that.  It's
  // tricky code to write and understand.
  return requireUsersSession(request, async (auth) => {
    let project = await getProject();
    return project;

// NEW
// src/utils/session.js
async function requireUserSession(request) {
  let cookie = request.headers.get("Cookie");
  let session = await getSession(cookie);
  let auth = session.get("auth");

  if (!auth) {
    // stop executing code, there's no reason to keep going because we want to
    // go somewhere else
    throw redirect("/login");

  // otherwise just return the auth, no callback higher-order-function-academics
  return auth;

// src/routes/some-route.js
export async function loader({ request }) {
  // simply await auth, if it's not there, code will stop executing and the user
  // will be redirected
  let auth = await requireUsersSession(request);
  let project = await getProject();
  return project;

This is really nice when you have multiple utilities in play:

// OLD
export let loader = async ({ request }) => {
  return removeTrailingSlash(request.url, () => {
    return withSession(request, (session) => {
      return requireUser(session, (user) => {
        return json(user);

// NEW
export let loader = async ({ request }) => {
  let session = await withSession(request);
  let user = await requireUser(session);
  return json(user);

It's very similar to how React hooks elminated the "pyramid of death" with render props by changing from a "push api" (the data is pushed to a callback) to a "pull api" (the data is simply returned). It not only cleans up your loaders, but makes it easier to compose different loader utilities together.

Splat route file convention

React Router has always supported routes ending in * but the only way to do it in Remix was with remix.config.js. Now you can define a "splat route" by naming the file $. For example:

  • routes/$.js - will match everything that doesn't match another route
  • routes/docs/$version/$.js - will match everyting under urls like docs/v0.19/guides/installation and will be nested under routes/docs/$version.js.
  • routes/files.$.js - will match all urls under files/ without any nesting since it's a . instead of a nested folder.

Layout Route file convention

Routes in React Router (and therefore Remix) add both segments to the URL and layouts to the UI. Sometimes you need a segment without a new layout, other times you want a layout without a new segment. You can now add layouts without adding any path segments. In React Router it looks like this:

  <Route element={<Public />}>
    <Route index element={<Index />} />
    <Route path="/contact" element={<Contact />} />
    <Route path="/login" element={<Login />} />
  <Route element={<Authenticated />}>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/projects" element={<Projects />} />

At "/contact", React Router will render <Public><Contact/></Public>. At "/dashboard", React Router will render <Authenticated><Contact/></Authenticated>. You'll notce two routes up there add layouts, but they don't add any path segments to the URL. They are essentially ignored for matching but used while rendering.

In Remix you can now configure these "layout routes" by prefixing your route (and its child-routes folder) with two underscores like __public. From there it works like any other nested routes, they just don't add path segments to the URL.

└── routes
    ├── __authenticated.js
    ├── __authenticated
    │   ├── dashboard.js
    │   └── projects.js
    ├── __public.js
    └── __public
        ├── contact.js
        ├── index.js
        └── login.js

?index in form actions

Appending ?index to a <Form action="/projects?index"> tells Remix to post to the routes/projects/index.js route rather than the parent route at routes/projects.js.

We had a strange case where simply defining an index route under a parent route would completely change the behavior of the app. It was especially confusing when you leave off the action completely like <Form method="post">. If you did not have an index route, it would post to the parent route as expected. If you added an index route under the parent, it would suddenly start posting there!

Now, if you use a <Form method="post"> inside of a parent route, it will post to the parent. If it's in the index route without an action, it will post to the index route. When you're defining the action, you can tell Remix which route to post to with ?index

// posts to src/routes/projects.js
<Form action="/projects" method="post" />

// posts to src/routes/projects/index.js
<Form action="/projects?index" method="post" />

// posts to which ever route in which it's rendering
<Form method="post" />

Extending MDX Plugins

You can now provide plugins to MDX in remix.config:

exports.mdx = async filename => {
  return {
    remarkPlugins: [require("remark-toc"),
    rehypePlugins: [require("rehype-highlight")]

HEAD requests

Remix now automatically handles HEAD requests that come to your server. They're exactly like GET, except the client is asking for just the HTTP headers. Remix does everything it normally does, but at the end strips the body from the response (it has to do a normal render of the app in order for headers like "content-length" to be accurate).

That's quite enough for one release! Enjoy!