React Router v7 has been released. View the docs
Upgrading to v2
On this page

Upgrading to v2

This documentation provides guidance on migrating from v1 to v2 while using the Classic Remix compiler. For additional guidance on migrating to Vite, refer to the Remix Vite documentation.

All v2 APIs and behaviors are available in v1 with Future Flags. They can be enabled one at a time to avoid development disruption of your project. After you have enabled all flags, upgrading to v2 should be a non-breaking upgrade.

If you're having trouble see the Troubleshooting section.

For a quick walkthrough of some common upgrade issues checkout 🎥 2 minutes to v2.

remix dev

For configuration options, see the remix dev docs.

remix-serve

If you are using the Remix App Server (remix-serve), enable v2_dev:

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  future: {
    v2_dev: true,
  },
};

That's it!

Custom app server

If you are using your own app server (server.js), then check out our templates for examples of how to integrate with v2_dev or follow these steps:

  1. Enable v2_dev:

    /** @type {import('@remix-run/dev').AppConfig} */
    module.exports = {
      future: {
        v2_dev: true,
      },
    };
    
  2. Update scripts in package.json:

    • Replace any remix watch with remix dev
    • Remove redundant NODE_ENV=development
    • Use -c / --command to run your app server

    For example:

     {
       "scripts": {
    -    "dev:remix": "cross-env NODE_ENV=development remix watch",
    -    "dev:server": "cross-env NODE_ENV=development node ./server.js"
    +    "dev": "remix dev -c 'node ./server.js'",
       }
     }
    
  3. Send a "ready" message to the Remix compiler once your app is running

    import { broadcastDevReady } from "@remix-run/node";
    // import { logDevReady } from "@remix-run/cloudflare" // use `logDevReady` if using CloudFlare
    
    const BUILD_DIR = path.join(process.cwd(), "build");
    
    // ... code setting up your server goes here ...
    
    const port = 3000;
    app.listen(port, async () => {
      console.log(`👉 http://localhost:${port}`);
      broadcastDevReady(await import(BUILD_DIR));
    });
    
  4. (Optional) --manual

    If you were relying on require cache purging, you can keep doing so by using the --manual flag:

    remix dev --manual -c 'node ./server.js'
    

    Check out the manual mode guide for more details.

After upgrading from v1 to v2

After you've enabled the future.v2_dev flag in v1 and gotten that working, you're ready to upgrade to v2. If you just had v2_dev set to true, you can remove it and things should work.

If you are using v2_dev config, you'll need to move it to the dev config field:

  /** @type {import('@remix-run/dev').AppConfig} */
  module.exports = {
-   future: {
-     v2_dev: {
-       port: 4004
-     }
-   }
+   dev: {
+     port: 4004
+   }
  }

File System Route Convention

Upgrading without changing files

You can keep using the old convention with @remix-run/v1-route-convention even after upgrading to v2 if you don't want to make the change right now (or ever, it's just a convention, and you can use whatever file organization you prefer).

npm i -D @remix-run/v1-route-convention
const {
  createRoutesFromFolders,
} = require("@remix-run/v1-route-convention");

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  future: {
    // makes the warning go away in v1.15+
    v2_routeConvention: true,
  },

  routes(defineRoutes) {
    // uses the v1 convention, works in v1.15+ and v2
    return createRoutesFromFolders(defineRoutes);
  },
};

Upgrading to the new convention

  • Route nesting is now created by dots (.) in file names instead of folder nesting
  • suffixed_ underscores in segments opt-out of nesting with a potentially matching parent route instead of dots (.).
  • _prefixed underscores in segments create layout routes without a path instead of a __double underscore prefix.
  • _index.tsx files create index routes instead of index.tsx

A routes folder that looks like this in v1:

app/
├── routes/
│   ├── __auth/
│   │   ├── login.tsx
│   │   ├── logout.tsx
│   │   └── signup.tsx
│   ├── __public/
│   │   ├── about-us.tsx
│   │   ├── contact.tsx
│   │   └── index.tsx
│   ├── dashboard/
│   │   ├── calendar/
│   │   │   ├── $day.tsx
│   │   │   └── index.tsx
│   │   ├── projects/
│   │   │   ├── $projectId/
│   │   │   │   ├── collaborators.tsx
│   │   │   │   ├── edit.tsx
│   │   │   │   ├── index.tsx
│   │   │   │   ├── settings.tsx
│   │   │   │   └── tasks.$taskId.tsx
│   │   │   ├── $projectId.tsx
│   │   │   └── new.tsx
│   │   ├── calendar.tsx
│   │   ├── index.tsx
│   │   └── projects.tsx
│   ├── __auth.tsx
│   ├── __public.tsx
│   └── dashboard.projects.$projectId.print.tsx
└── root.tsx

Becomes this with v2_routeConvention:

app/
├── routes/
│   ├── _auth.login.tsx
│   ├── _auth.logout.tsx
│   ├── _auth.signup.tsx
│   ├── _auth.tsx
│   ├── _public._index.tsx
│   ├── _public.about-us.tsx
│   ├── _public.contact.tsx
│   ├── _public.tsx
│   ├── dashboard._index.tsx
│   ├── dashboard.calendar._index.tsx
│   ├── dashboard.calendar.$day.tsx
│   ├── dashboard.calendar.tsx
│   ├── dashboard.projects.$projectId._index.tsx
│   ├── dashboard.projects.$projectId.collaborators.tsx
│   ├── dashboard.projects.$projectId.edit.tsx
│   ├── dashboard.projects.$projectId.settings.tsx
│   ├── dashboard.projects.$projectId.tasks.$taskId.tsx
│   ├── dashboard.projects.$projectId.tsx
│   ├── dashboard.projects.new.tsx
│   ├── dashboard.projects.tsx
│   └── dashboard_.projects.$projectId.print.tsx
└── root.tsx

Note that parent routes are now grouped together instead of having dozens of routes between them (like the auth routes). Routes with the same path but not the same nesting (like dashboard and dashboard_) also group together.

With the new convention, any route can be a directory with a route.tsx file inside to define the route module. This enables co-location of modules with the route they're used in:

For example, we can move _public.tsx to _public/route.tsx and then co-locate modules the route uses:

app/
├── routes/
│   ├── _auth.tsx
│   ├── _public/
│   │   ├── footer.tsx
│   │   ├── header.tsx
│   │   └── route.tsx
│   ├── _public._index.tsx
│   ├── _public.about-us.tsx
│   └── etc.
└── root.tsx

For more background on this change, see the original "flat routes" proposal.

Route headers

In Remix v2, the behavior for route headers functions has changed slightly. You can opt-into this new behavior ahead of time via the future.v2_headers flag in remix.config.js.

In v1, Remix would only use the result of the leaf "rendered" route headers function. It was your responsibility to add a headers function to every potential leaf and merge in parentHeaders accordingly. This can get tedious quickly and is also easy to forget to add a headers function when you add a new route, even if you want it to just share the same headers from its parent.

In v2, Remix now uses the deepest headers function that it finds in the rendered routes. This more easily allows you to share headers across routes from a common ancestor. Then as needed you can add headers functions to deeper routes if they require specific behavior.

Route meta

In Remix v2, the signature for route meta functions and how Remix handles meta tags under the hood have changed.

Instead of returning an object from meta, you will now return an array of descriptors and manage the merge yourself. This brings the meta API closer to links, and it allows for more flexibility and control over how meta tags are rendered.

In addition, <Meta /> will no longer render meta for every route in the hierarchy. Only data returned from meta in the leaf route will be rendered. You can still choose to include meta from the parent route by accessing matches in the function's arguments.

For more background on this change, see the original v2 meta proposal.

Using v1 meta conventions in v2

You can update your meta exports with the @remix-run/v1-meta package to continue using v1 conventions.

Using the metaV1 function, you can pass in the meta function's arguments and the same object it currently returns. This function will use the same merging logic to merge the leaf route's meta with its direct parent route meta before converting it to an array of meta descriptors usable in v2.

export function meta() {
  return {
    title: "...",
    description: "...",
    "og:title": "...",
  };
}
import { metaV1 } from "@remix-run/v1-meta";

export function meta(args) {
  return metaV1(args, {
    title: "...",
    description: "...",
    "og:title": "...",
  });
}

It's important to note that this function will not merge meta across the entire hierarchy by default. This is because you may have some routes that return an array of objects directly without the metaV1 function and this could result in unpredictable behavior. If you want to merge meta across the entire hierarchy, use the metaV1 function for all of your route's meta exports.

The parentsData argument

In v2, the meta function no longer receives the parentsData argument. This is because meta now has access to all of your route matches via the matches argument, which includes loader data for each match.

To replicate the API of parentsData, the @remix-run/v1-meta package provides a getMatchesData function. It returns an object where the data for each match is keyed by the route's ID.

export function meta(args) {
  const parentData = args.parentsData["routes/parent"];
}

Becomes:

import { getMatchesData } from "@remix-run/v1-meta";

export function meta(args) {
  const matchesData = getMatchesData(args);
  const parentData = matchesData["routes/parent"];
}

Updating to the new meta

export function meta() {
  return {
    title: "...",
    description: "...",
    "og:title": "...",
  };
}
export function meta() {
  return [
    { title: "..." },
    { name: "description", content: "..." },
    { property: "og:title", content: "..." },

    // you can now add SEO related <links>
    { tagName: "link", rel: "canonical", href: "..." },

    // and <script type=ld+json>
    {
      "script:ld+json": {
        some: "value",
      },
    },
  ];
}

The matches argument

Note that in v1 the objects returned from nested routes were all merged, you will need to manage the merge yourself now with matches:

export function meta({ matches }) {
  const rootMeta = matches[0].meta;
  const title = rootMeta.find((m) => m.title);

  return [
    title,
    { name: "description", content: "..." },
    { property: "og:title", content: "..." },

    // you can now add SEO related <links>
    { tagName: "link", rel: "canonical", href: "..." },

    // and <script type=ld+json>
    {
      "script:ld+json": {
        "@context": "https://schema.org",
        "@type": "Organization",
        name: "Remix",
      },
    },
  ];
}

The meta docs have more tips on merging route meta.

CatchBoundary and ErrorBoundary

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  future: {
    v2_errorBoundary: true,
  },
};

In v1, a thrown Response rendered the closest CatchBoundary while all other unhandled exceptions rendered the ErrorBoundary. In v2 there is no CatchBoundary and all unhandled exceptions will render the ErrorBoundary, response or otherwise.

Additionally, the error is no longer passed to ErrorBoundary as props but is accessed with the useRouteError hook.

import { useCatch } from "@remix-run/react";

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

  return (
    <div>
      <h1>Oops</h1>
      <p>Status: {caught.status}</p>
      <p>{caught.data.message}</p>
    </div>
  );
}

export function ErrorBoundary({ error }) {
  console.error(error);
  return (
    <div>
      <h1>Uh oh ...</h1>
      <p>Something went wrong</p>
      <pre>{error.message || "Unknown error"}</pre>
    </div>
  );
}

Becomes:

import {
  useRouteError,
  isRouteErrorResponse,
} from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();

  // when true, this is what used to go to `CatchBoundary`
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>Oops</h1>
        <p>Status: {error.status}</p>
        <p>{error.data.message}</p>
      </div>
    );
  }

  // Don't forget to typecheck with your own logic.
  // Any value can be thrown, not just errors!
  let errorMessage = "Unknown error";
  if (isDefinitelyAnError(error)) {
    errorMessage = error.message;
  }

  return (
    <div>
      <h1>Uh oh ...</h1>
      <p>Something went wrong.</p>
      <pre>{errorMessage}</pre>
    </div>
  );
}

formMethod

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  future: {
    v2_normalizeFormMethod: true,
  },
};

Multiple APIs return the formMethod of a submission. In v1 they returned a lowercase version of the method but in v2 they return the UPPERCASE version. This is to bring it in line with HTTP and fetch specifications.

function Something() {
  const navigation = useNavigation();

  // v1
  navigation.formMethod === "post";

  // v2
  navigation.formMethod === "POST";
}

export function shouldRevalidate({ formMethod }) {
  // v1
  formMethod === "post";

  // v2
  formMethod === "POST";
}

useTransition

This hook is now called useNavigation to avoid confusion with the recent React hook by the same name. It also no longer has the type field and flattens the submission object into the navigation object itself.

import { useTransition } from "@remix-run/react";

function SomeComponent() {
  const transition = useTransition();
  transition.submission.formData;
  transition.submission.formMethod;
  transition.submission.formAction;
  transition.type;
}
import { useNavigation } from "@remix-run/react";

function SomeComponent() {
  const navigation = useNavigation();

  // transition.submission keys are flattened onto `navigation[key]`
  navigation.formData;
  navigation.formMethod;
  navigation.formAction;

  // this key is removed
  navigation.type;
}

You can derive the previous transition.type with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking navigation.state, navigation.formData or the data returned from an action with useActionData can get the UX you're looking for. Feel free to ask us in Discord, and we'll help you out :D

function Component() {
  const navigation = useNavigation();

  // transition.type === "actionSubmission"
  const isActionSubmission =
    navigation.state === "submitting";

  // transition.type === "actionReload"
  const isActionReload =
    navigation.state === "loading" &&
    navigation.formMethod != null &&
    navigation.formMethod != "GET" &&
    // We had a submission navigation and are loading the submitted location
    navigation.formAction === navigation.location.pathname;

  // transition.type === "actionRedirect"
  const isActionRedirect =
    navigation.state === "loading" &&
    navigation.formMethod != null &&
    navigation.formMethod != "GET" &&
    // We had a submission navigation and are now navigating to different location
    navigation.formAction !== navigation.location.pathname;

  // transition.type === "loaderSubmission"
  const isLoaderSubmission =
    navigation.state === "loading" &&
    navigation.state.formMethod === "GET" &&
    // We had a loader submission and are navigating to the submitted location
    navigation.formAction === navigation.location.pathname;

  // transition.type === "loaderSubmissionRedirect"
  const isLoaderSubmissionRedirect =
    navigation.state === "loading" &&
    navigation.state.formMethod === "GET" &&
    // We had a loader submission and are navigating to a new location
    navigation.formAction !== navigation.location.pathname;
}

A note on GET submissions

In Remix v1, GET submissions such as <Form method="get"> or submit({}, { method: 'get' }) went from idle -> submitting -> idle in transition.state. This is not quite semantically correct since even though you're "submitting" a form, you're performing a GET navigation and only executing loaders (not actions). Functionally, it's no different from a <Link> or navigate() except that the user may be specifying the search param values via inputs.

In v2, GET submissions are more accurately reflected as loading navigations and thus go idle -> loading -> idle to align navigation.state with the behavior of normal links. If your GET submission came from a <Form> or submit(), then useNavigation.form* will be populated, so you can differentiate if needed.

useFetcher

Like useNavigation, useFetcher has flattened the submission and removed the type field.

import { useFetcher } from "@remix-run/react";

function SomeComponent() {
  const fetcher = useFetcher();
  fetcher.submission.formData;
  fetcher.submission.formMethod;
  fetcher.submission.formAction;
  fetcher.type;
}
import { useFetcher } from "@remix-run/react";

function SomeComponent() {
  const fetcher = useFetcher();

  // these keys are flattened
  fetcher.formData;
  fetcher.formMethod;
  fetcher.formAction;

  // this key is removed
  fetcher.type;
}

You can derive the previous fetcher.type with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking fetcher.state, fetcher.formData or the data returned from an action on fetcher.data can get the UX you're looking for. Feel free to ask us in Discord, and we'll help you out :D

function Component() {
  const fetcher = useFetcher();

  // fetcher.type === "init"
  const isInit =
    fetcher.state === "idle" && fetcher.data == null;

  // fetcher.type === "done"
  const isDone =
    fetcher.state === "idle" && fetcher.data != null;

  // fetcher.type === "actionSubmission"
  const isActionSubmission = fetcher.state === "submitting";

  // fetcher.type === "actionReload"
  const isActionReload =
    fetcher.state === "loading" &&
    fetcher.formMethod != null &&
    fetcher.formMethod != "GET" &&
    // If we returned data, we must be reloading
    fetcher.data != null;

  // fetcher.type === "actionRedirect"
  const isActionRedirect =
    fetcher.state === "loading" &&
    fetcher.formMethod != null &&
    fetcher.formMethod != "GET" &&
    // If we have no data we must have redirected
    fetcher.data == null;

  // fetcher.type === "loaderSubmission"
  const isLoaderSubmission =
    fetcher.state === "loading" &&
    fetcher.formMethod === "GET";

  // fetcher.type === "normalLoad"
  const isNormalLoad =
    fetcher.state === "loading" &&
    fetcher.formMethod == null;
}

A note on GET submissions

In Remix v1, GET submissions such as <fetcher.Form method="get"> or fetcher.submit({}, { method: 'get' }) went from idle -> submitting -> idle in fetcher.state. This is not quite semantically correct since even though you're "submitting" a form, you're performing a GET request and only executing a loader (not an action). Functionally, it's no different from a fetcher.load() except that the user may be specifying the search param values via inputs.

In v2, GET submissions are more accurately reflected as loading requests and thus go idle -> loading -> idle to align fetcher.state with the behavior of normal fetcher loads. If your GET submission came from a <fetcher.Form> or fetcher.submit(), then fetcher.form* will be populated, so you can differentiate if needed.

Route links properties should all be the React camelCase values instead of HTML lowercase values. These two values snuck in as lowercase in v1. In v2 only the camelCase versions are valid:

export const links: LinksFunction = () => {
  return [
    {
      rel: "preload",
      as: "image",
      imagesrcset: "...",
      imagesizes: "...",
    },
  ];
};
export const links: V2_LinksFunction = () => {
  return [
    {
      rel: "preload",
      as: "image",
      imageSrcSet: "...",
      imageSizes: "...",
    },
  ];
};

browserBuildDirectory

In your remix.config.js, rename browserBuildDirectory to assetsBuildDirectory.

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  browserBuildDirectory: "./public/build",
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  assetsBuildDirectory: "./public/build",
};

devServerBroadcastDelay

Remove devServerBroadcastDelay from your remix.config.js as the race conditions that necessitated this option have been eliminated in v2 or with v2_dev.

  /** @type {import('@remix-run/dev').AppConfig} */
  module.exports = {
-   devServerBroadcastDelay: 300,
  };

devServerPort

In your remix.config.js, rename devServerPort to future.v2_dev.port.

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  devServerPort: 8002,
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  // While on v1.x, this is via a future flag
  future: {
    v2_dev: {
      port: 8002,
    },
  },
};

Once you upgrade from v1 to v2, this flattens to a root-level dev config.

serverBuildDirectory

In your remix.config.js, rename serverBuildDirectory to serverBuildPath and specify a module path, not a directory.

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverBuildDirectory: "./build",
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverBuildPath: "./build/index.js",
};

Remix used to create more than a single module for the server, but it now creates a single file.

serverBuildTarget

Instead of specifying a build target, use the remix.config.js options to generate the server build your server target expects. This change allows Remix to deploy to more JavaScript runtimes, servers, and hosts without Remix source code needing to know about them.

The following configurations should replace your current serverBuildTarget:

arc

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  publicPath: "/_static/build/",
  serverBuildPath: "server/index.js",
  serverMainFields: ["main", "module"], // default value, can be removed
  serverMinify: false, // default value, can be removed
  serverModuleFormat: "cjs", // default value in 1.x, add before upgrading
  serverPlatform: "node", // default value, can be removed
};

cloudflare-pages

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  publicPath: "/build/", // default value, can be removed
  serverBuildPath: "functions/[[path]].js",
  serverConditions: ["worker"],
  serverDependenciesToBundle: "all",
  serverMainFields: ["browser", "module", "main"],
  serverMinify: true,
  serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
  serverPlatform: "neutral",
};

cloudflare-workers

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  publicPath: "/build/", // default value, can be removed
  serverBuildPath: "build/index.js", // default value, can be removed
  serverConditions: ["worker"],
  serverDependenciesToBundle: "all",
  serverMainFields: ["browser", "module", "main"],
  serverMinify: true,
  serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
  serverPlatform: "neutral",
};

deno

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  publicPath: "/build/", // default value, can be removed
  serverBuildPath: "build/index.js", // default value, can be removed
  serverConditions: ["deno", "worker"],
  serverDependenciesToBundle: "all",
  serverMainFields: ["module", "main"],
  serverMinify: false, // default value, can be removed
  serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
  serverPlatform: "neutral",
};

node-cjs

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  publicPath: "/build/", // default value, can be removed
  serverBuildPath: "build/index.js", // default value, can be removed
  serverMainFields: ["main", "module"], // default value, can be removed
  serverMinify: false, // default value, can be removed
  serverModuleFormat: "cjs", // default value in 1.x, add before upgrading
  serverPlatform: "node", // default value, can be removed
};

serverModuleFormat

The default server module output format has changed from cjs to esm. You can continue to use CJS in v2, many dependencies in your app might not be compatible with ESM.

In your remix.config.js, you should specify either serverModuleFormat: "cjs" to retain existing behavior, or serverModuleFormat: "esm", to opt into the new behavior.

browserNodeBuiltinsPolyfill

Polyfills for Node.js built-in modules are no longer provided by default for the browser. In Remix v2 you'll need to explicitly reintroduce any polyfills (or blank polyfills) as required:

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  browserNodeBuiltinsPolyfill: {
    modules: {
      buffer: true,
      fs: "empty",
    },
    globals: {
      Buffer: true,
    },
  },
};

Even though we recommend being explicit about which polyfills are allowed in your browser bundle, especially since some polyfills can be quite large, you can quickly reinstate the full set of polyfills from Remix v1 with the following configuration:

const { builtinModules } = require("node:module");

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  browserNodeBuiltinsPolyfill: {
    modules: builtinModules,
  },
};

serverNodeBuiltinsPolyfill

Polyfills for Node.js built-in modules are no longer be provided by default for non-Node.js server platforms.

If you are targeting a non-Node.js server platform and want to opt into the new default behavior in v1, in remix.config.js you should first remove all server polyfills by explicitly providing an empty object for serverNodeBuiltinsPolyfill.modules:

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverNodeBuiltinsPolyfill: {
    modules: {},
  },
};

You can then reintroduce any polyfills (or blank polyfills) as required.

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverNodeBuiltinsPolyfill: {
    modules: {
      buffer: true,
      fs: "empty",
    },
    globals: {
      Buffer: true,
    },
  },
};

For reference, the complete set of default polyfills from v1 can be manually specified as follows:

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  serverNodeBuiltinsPolyfill: {
    modules: {
      _stream_duplex: true,
      _stream_passthrough: true,
      _stream_readable: true,
      _stream_transform: true,
      _stream_writable: true,
      assert: true,
      "assert/strict": true,
      buffer: true,
      console: true,
      constants: true,
      crypto: "empty",
      diagnostics_channel: true,
      domain: true,
      events: true,
      fs: "empty",
      "fs/promises": "empty",
      http: true,
      https: true,
      module: true,
      os: true,
      path: true,
      "path/posix": true,
      "path/win32": true,
      perf_hooks: true,
      process: true,
      punycode: true,
      querystring: true,
      stream: true,
      "stream/promises": true,
      "stream/web": true,
      string_decoder: true,
      sys: true,
      timers: true,
      "timers/promises": true,
      tty: true,
      url: true,
      util: true,
      "util/types": true,
      vm: true,
      wasi: true,
      worker_threads: true,
      zlib: true,
    },
  },
};

installGlobals

For preparation of using Node's built in fetch implementation, installing the fetch globals is now a responsibility of the app server. If you are using remix-serve, nothing is required. If you are using your own app server, you will need to install the globals yourself.

import { installGlobals } from "@remix-run/node";

installGlobals();

Removal of exported polyfills

Remix v2 also no longer exports these polyfilled implementations from @remix-run/node, and instead you should just use the instances in the global namespace. One place this is likely to surface and require a change is your app/entry.server.tsx file, where you'll also need to convert the Node PassThrough into a web ReadableStream via createReadableStreamFromReadable:

  import { PassThrough } from "node:stream";
  import type { AppLoadContext, EntryContext } from "@remix-run/node"; // or cloudflare/deno
- import { Response } from "@remix-run/node"; // or cloudflare/deno
+ import { createReadableStreamFromReadable } from "@remix-run/node"; // or cloudflare/deno
  import { RemixServer } from "@remix-run/react";
  import { isbot } from "isbot";
  import { renderToPipeableStream } from "react-dom/server";

  const ABORT_DELAY = 5_000;

  export default function handleRequest({ /* ... */ }) { ... }

  function handleBotRequest(...) {
    return new Promise((resolve, reject) => {
      let shellRendered = false;
      const { pipe, abort } = renderToPipeableStream(
        <RemixServer ... />,
        {
          onAllReady() {
            shellRendered = true;
            const body = new PassThrough();

            responseHeaders.set("Content-Type", "text/html");

            resolve(
-             new Response(body, {
+             new Response(createReadableStreamFromReadable(body), {
                headers: responseHeaders,
                status: responseStatusCode,
              })
            );

            pipe(body);
          },
          ...
          onShellError(error: unknown) { ... }
          onError(error: unknown) { ... }
        }
      );

      setTimeout(abort, ABORT_DELAY);
    });
  }

  function handleBrowserRequest(...) {
    return new Promise((resolve, reject) => {
      let shellRendered = false;
      const { pipe, abort } = renderToPipeableStream(
        <RemixServer ... />,
        {
          onShellReady() {
            shellRendered = true;
            const body = new PassThrough();

            responseHeaders.set("Content-Type", "text/html");

            resolve(
-              new Response(body, {
+              new Response(createReadableStreamFromReadable(body), {
                headers: responseHeaders,
                status: responseStatusCode,
              })
            );

            pipe(body);
          },
          onShellError(error: unknown) { ... },
          onError(error: unknown) { ... },
        }
      );

      setTimeout(abort, ABORT_DELAY);
    });
  }

source-map-support

Source map support is now a responsibility of the app server. If you are using remix-serve, nothing is required. If you are using your own app server, you will need to install source-map-support yourself.

npm i source-map-support
import sourceMapSupport from "source-map-support";

sourceMapSupport.install();

Netlify adapter

The @remix-run/netlify runtime adapter has been deprecated in favor of @netlify/remix-adapter & @netlify/remix-edge-adapter and is now removed as of Remix v2. Please update your code by changing all @remix-run/netlify imports to @netlify/remix-adapter.
Keep in mind that @netlify/remix-adapter requires @netlify/functions@^1.0.0, which is a breaking change compared to the current supported @netlify/functions versions in @remix-run/netlify.

Due to the removal of this adapter, we also removed our Netlify template in favor of the official Netlify template.

Vercel adapter

The @remix-run/vercel runtime adapter has been deprecated in favor of out of the box Vercel functionality and is now removed as of Remix v2. Please update your code by removing @remix-run/vercel & @vercel/node from your package.json, removing your server.js/server.ts file, and removing the server & serverBuildPath options from your remix.config.js.

Due to the removal of this adapter, we also removed our Vercel template in favor of the official Vercel template.

Built-in PostCSS/Tailwind support

In v2, these tools are automatically used within the Remix compiler if PostCSS and/or Tailwind configuration files are present in your project.

If you have a custom PostCSS and/or Tailwind setup outside of Remix that you'd like to maintain when migrating to v2, you can disable these features in your remix.config.js.

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  postcss: false,
  tailwind: false,
};

Troubleshooting

ESM / CommonJS Errors

"SyntaxError: Named export '<something>' not found. The requested module '<something>' is a CommonJS module, which may not support all module.exports as named exports."

Please see the serverModuleFormat section.

Docs and examples licensed under MIT