If you want a TL;DR version along with a repo outlining a simplified migration, check out our example React Router-to-Remix repo.

Migrating your React Router App to Remix

Millions of React applications deployed worldwide are powered by React Router. Chances are you’ve shipped a few of them! Because Remix is built on top of React Router, we have worked to make migration an easy process you can work through iteratively to avoid huge refactors.

If you aren’t already using React Router, we think there are several compelling reasons to reconsider! History management, dynamic path matching, nested routing, and much more. Take a look at the React Router docs and see all what we have to offer.

Ensure your app uses React Router v6

If you are using an older version of React Router, the first step is to upgrade to v6. Check out the migration guide from v5 to v6 and our backwards compatibility package to upgrade your app to v6 quickly and iteratively.

Installing Remix

First, you’ll need a few of our packages to build on Remix. Follow the instructions below, running all commands from the root of your project.

  1. npm install @remix-run/react @remix-run/node @remix-run/serve
  2. npm install -D @remix-run/dev

Creating server and browser entrypoints

Most React Router apps run primarily in the browser. The server’s only job is to send a single static HTML page while React Router manages the route-based views client-side. These apps generally have a browser entrypoint file like a root index.js that looks something like this:

  1. import { render } from "react-dom";
  2. import App from "./App";
  3. render(<App />, document.getElementById("app"));

Server-rendered React apps are a little different. The browser script is not rendering your app, but is “hydrating” the DOM provided by the server. Hydration is the process of mapping the elements in the DOM to their React component counterparts and setting up event listeners so that your app is interactive.

Let’s start by creating two new files:

  • app/entry.server.tsx (or entry.server.jsx)
  • app/entry.client.tsx (or entry.client.jsx)

All of your app code in Remix will live in an app directory by convention. If your existing app uses a directory with the same name, rename it to something like src or old-app to differentiate as we migrate to Remix.

  1. import { PassThrough } from "stream";
  2. import type {
  3. AppLoadContext,
  4. EntryContext,
  5. } from "@remix-run/node";
  6. import { Response } from "@remix-run/node";
  7. import { RemixServer } from "@remix-run/react";
  8. import isbot from "isbot";
  9. import { renderToPipeableStream } from "react-dom/server";
  10. const ABORT_DELAY = 5_000;
  11. export default function handleRequest(
  12. request: Request,
  13. responseStatusCode: number,
  14. responseHeaders: Headers,
  15. remixContext: EntryContext,
  16. loadContext: AppLoadContext
  17. ) {
  18. return isbot(request.headers.get("user-agent"))
  19. ? handleBotRequest(
  20. request,
  21. responseStatusCode,
  22. responseHeaders,
  23. remixContext
  24. )
  25. : handleBrowserRequest(
  26. request,
  27. responseStatusCode,
  28. responseHeaders,
  29. remixContext
  30. );
  31. }
  32. function handleBotRequest(
  33. request: Request,
  34. responseStatusCode: number,
  35. responseHeaders: Headers,
  36. remixContext: EntryContext
  37. ) {
  38. return new Promise((resolve, reject) => {
  39. const { pipe, abort } = renderToPipeableStream(
  40. <RemixServer
  41. context={remixContext}
  42. url={request.url}
  43. abortDelay={ABORT_DELAY}
  44. />,
  45. {
  46. onAllReady() {
  47. const body = new PassThrough();
  48. responseHeaders.set("Content-Type", "text/html");
  49. resolve(
  50. new Response(body, {
  51. headers: responseHeaders,
  52. status: responseStatusCode,
  53. })
  54. );
  55. pipe(body);
  56. },
  57. onShellError(error: unknown) {
  58. reject(error);
  59. },
  60. onError(error: unknown) {
  61. responseStatusCode = 500;
  62. console.error(error);
  63. },
  64. }
  65. );
  66. setTimeout(abort, ABORT_DELAY);
  67. });
  68. }
  69. function handleBrowserRequest(
  70. request: Request,
  71. responseStatusCode: number,
  72. responseHeaders: Headers,
  73. remixContext: EntryContext
  74. ) {
  75. return new Promise((resolve, reject) => {
  76. const { pipe, abort } = renderToPipeableStream(
  77. <RemixServer
  78. context={remixContext}
  79. url={request.url}
  80. abortDelay={ABORT_DELAY}
  81. />,
  82. {
  83. onShellReady() {
  84. const body = new PassThrough();
  85. responseHeaders.set("Content-Type", "text/html");
  86. resolve(
  87. new Response(body, {
  88. headers: responseHeaders,
  89. status: responseStatusCode,
  90. })
  91. );
  92. pipe(body);
  93. },
  94. onShellError(error: unknown) {
  95. reject(error);
  96. },
  97. onError(error: unknown) {
  98. console.error(error);
  99. responseStatusCode = 500;
  100. },
  101. }
  102. );
  103. setTimeout(abort, ABORT_DELAY);
  104. });
  105. }

If you are using React 17, your client entrypoint will look like this:

  1. import { RemixBrowser } from "@remix-run/react";
  2. import { hydrate } from "react-dom";
  3. hydrate(<RemixBrowser />, document);

In React 18, you’ll use hydrateRoot instead of hydrate.

  1. import { RemixBrowser } from "@remix-run/react";
  2. import { startTransition, StrictMode } from "react";
  3. import { hydrateRoot } from "react-dom/client";
  4. startTransition(() => {
  5. hydrateRoot(
  6. document,
  7. <StrictMode>
  8. <RemixBrowser />
  9. </StrictMode>
  10. );
  11. });

Creating The root route

We mentioned that Remix is built on top of React Router. Your app likely renders a BrowserRouter with your routes defined in JSX Route components. We don’t need to do that in Remix, but more on that later. For now, we need to provide the lowest level route our Remix app needs to work.

The root route (or the “root root” if you’re Wes Bos) is responsible for providing the structure of the application. Its default export is a component that renders the full HTML tree that every other route loads and depends on. Think of it as the scaffold or shell of your app.

In a client-rendered app, you will have an index HTML file that includes the DOM node for mounting your React app. The root route will render markup that mirrors the structure of this file.

Create a new file called root.tsx (or root.jsx) in your app directory. The contents of that file will vary, but let’s assume that your index.html looks something like this:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8" />
  5. <link rel="icon" href="/favicon.ico" />
  6. <meta
  7. name="viewport"
  8. content="width=device-width, initial-scale=1"
  9. />
  10. <meta name="theme-color" content="#000000" />
  11. <meta
  12. name="description"
  13. content="My beautiful React app"
  14. />
  15. <link rel="apple-touch-icon" href="/logo192.png" />
  16. <link rel="manifest" href="/manifest.json" />
  17. <title>My React App</title>
  18. </head>
  19. <body>
  20. <noscript
  21. >You need to enable JavaScript to run this
  22. app.</noscript
  23. >
  24. <div id="root"></div>
  25. </body>
  26. </html>

In your root.tsx, export a component that mirrors its structure:

  1. import { Outlet } from "@remix-run/react";
  2. export default function Root() {
  3. return (
  4. <html lang="en">
  5. <head>
  6. <meta charSet="utf-8" />
  7. <link rel="icon" href="/favicon.ico" />
  8. <meta
  9. name="viewport"
  10. content="width=device-width, initial-scale=1"
  11. />
  12. <meta name="theme-color" content="#000000" />
  13. <meta
  14. name="description"
  15. content="My beautiful React app"
  16. />
  17. <link rel="apple-touch-icon" href="/logo192.png" />
  18. <link rel="manifest" href="/manifest.json" />
  19. <title>My React App</title>
  20. </head>
  21. <body>
  22. <div id="root">
  23. <Outlet />
  24. </div>
  25. </body>
  26. </html>
  27. );
  28. }

Notice a few things here:

  • We got rid of the noscript tag. We’re server rendering now, which means users who disable JavaScript will still be able to see our app (and over time, as you make a few tweaks to improve progressive enhancement, much of your app should still work).
  • Inside the root element we render an Outlet component from @remix-run/react. This is the same component that you would normally use to render your matched route in a React Router app; it serves the same function here, but it’s adapted for the router in Remix.

Important: be sure to delete the index.html from your public directory after you’ve created your root route. Keeping the file around may cause your server to send that HTML instead of your Remix app when accessing the / route.

Adapting your existing app code

First, move the root of your existing React code into your app directory. So if your root app code lives in an src directory in the project root, it should now be in app/src.

We also suggest renaming this directory to make it clear that this is your old code so that, eventually, you can delete it after migrating all of its contents. The beauty of this approach is that you don’t have to do it all at once for your app to run as usual. In our demo project we name this directory old-app.

Lastly, in your root App component (the one that would have been mounted to the root element), remove the <BrowserRouter> from React Router. Remix takes care of this for you without needing to render the provider directly.

Creating an index and a catch-all route

Remix needs routes beyond the root route to know what to render in <Outlet />. Fortunately you already render <Route> components in your app, and Remix can use those as you migrate to use our routing conventions.

To start, create a new directory in app called routes. In that directory, create two files called _index.tsx and $.tsx. $.tsx is called a catch-all route, and it will be useful to let your old app handle routes that you haven’t moved into the routes directory yet.

Inside your _index.tsx and $.tsx files, all we need to do is export the code from our old root App:

  1. export { default } from "~/old-app/app";
  1. export { default } from "~/old-app/app";

Replacing the bundler with Remix

Remix provides its own bundler and CLI tools for development and building your app. Chances are your app used something like Create React App to bootstrap, or perhaps you have a custom build set up with Webpack.

In your package.json file, update your scripts to use remix commands instead of your current build and dev scripts.

  1. {
  2. "scripts": {
  3. "build": "remix build",
  4. "dev": "remix dev",
  5. "start": "remix-serve build",
  6. "typecheck": "tsc"
  7. }
  8. }

And poof! Your app is now server-rendered and your build went from 90 seconds to 0.5 seconds ⚡

Creating your routes

Over time, you’ll want to migrate the routes rendered by React Router’s <Route> components into their own route files. The filenames and directory structure outlined in our routing conventions will guide this migration.

The default export in your route file is the component rendered in the <Outlet />. So if you have a route in your App that looks like this:

  1. function About() {
  2. return (
  3. <main>
  4. <h1>About us</h1>
  5. <PageContent />
  6. </main>
  7. );
  8. }
  9. function App() {
  10. return (
  11. <Routes>
  12. <Route path="/about" element={<About />} />
  13. </Routes>
  14. );
  15. }

Your route file should look like this:

  1. export default function About() {
  2. return (
  3. <main>
  4. <h1>About us</h1>
  5. <PageContent />
  6. </main>
  7. );
  8. }

Once you create this file, you can delete the <Route> component from your App. After all of your routes have been migrated you can delete <Routes> and ultimately all the code in old-app.

Gotchas and next steps

At this point you might be able to say you are done with the initial migration. Congrats! However, Remix does things a bit differently than your typical React app. If it didn’t, why would we have bothered building it in the first place? 😅

Unsafe browser references

A common pain-point in migrating a client-rendered codebase to a server-rendered one is that you may have references to browser APIs in code that runs on the server. A common example can be found when initializing values in state:

  1. function Count() {
  2. const [count, setCount] = React.useState(
  3. () => localStorage.getItem("count") || 0
  4. );
  5. React.useEffect(() => {
  6. localStorage.setItem("count", count);
  7. }, [count]);
  8. return (
  9. <div>
  10. <h1>Count: {count}</h1>
  11. <button onClick={() => setCount(count + 1)}>
  12. Increment
  13. </button>
  14. </div>
  15. );
  16. }

In this example, localStorage is used as a global store to persist some data across page reloads. We update localStorage with the current value of count in useEffect, which is perfectly safe because useEffect is only ever called in the browser! However, initializing state based on localStorage is a problem, as this callback is executed on both the server and in the browser.

Your go-to solution may be to check for the window object and only run the callback in the browser. However, this can lead to another problem, which is the dreaded hydration mismatch. React relies on markup rendered by the server to be identical to what is rendered during client hydration. This ensures that react-dom knows how to match DOM elements with their corresponding React components so that it can attach event listeners and perform updates as state changes. So if local storage gives us a different value than whatever we initiate on the server, we’ll have a new problem to deal with.

Client-only components

One potential solution here is using a different caching mechanism that can be used on the server and passed to the component via props passed from a route’s loader data. But if it isn’t crucial for your app to render the component on the server, a simpler solution may be to skip rendering altogether on the server and wait until hydration is complete to render it in the browser.

  1. // We can safely track hydration in memory state
  2. // outside of the component because it is only
  3. // updated once after the version instance of
  4. // `SomeComponent` has been hydrated. From there,
  5. // the browser takes over rendering duties across
  6. // route changes and we no longer need to worry
  7. // about hydration mismatches until the page is
  8. // reloaded and `isHydrating` is reset to true.
  9. let isHydrating = true;
  10. function SomeComponent() {
  11. const [isHydrated, setIsHydrated] = React.useState(
  12. !isHydrating
  13. );
  14. React.useEffect(() => {
  15. isHydrating = false;
  16. setIsHydrated(true);
  17. }, []);
  18. if (isHydrated) {
  19. return <Count />;
  20. } else {
  21. return <SomeFallbackComponent />;
  22. }
  23. }

To simplify this solution, we recommend the using the ClientOnly component in the remix-utils community package. An example of its usage can be found in the examples repository.

React.lazy and React.Suspense

If you are lazy-loading components with React.lazy and React.Suspense, you may run into issues depending on the version of React you are using. Until React 18, this would not work on the server as React.Suspense was originally implemented as a browser-only feature.

If you are using React 17, you have a few options:

Keep in mind that Remix automatically handles code-splitting for all your routes that it manages, so as you move things into the routes directory you should rarely—if ever—need to use React.lazy manually.

Configuration

Further configuration is optional, but the following may be helpful to optimize your development workflow.

remix.config.js

Every Remix app accepts a remix.config.js file in the project root. While its settings are optional, we recommend you include a few of them for clarity’s sake. See the docs on configuration for more information about all available options.

  1. /** @type {import('@remix-run/dev').AppConfig} */
  2. module.exports = {
  3. appDirectory: "app",
  4. ignoredRouteFiles: ["**/.*"],
  5. assetsBuildDirectory: "public/build",
  6. };

jsconfig.json or tsconfig.json

If you are using TypeScript, you likely already have a tsconfig.json in your project. jsconfig.json is optional but provides helpful context for many editors. These are the minimal settings we recommend including in your language configuration.

Remix uses the /_ path alias to easily import modules from the root no matter where your file lives in the project. If you change the appDirectory in your remix.config.js, you’ll need to update your path alias for /_ as well.

  1. {
  2. "compilerOptions": {
  3. "jsx": "react-jsx",
  4. "resolveJsonModule": true,
  5. "baseUrl": ".",
  6. "paths": {
  7. "~/*": ["./app/*"]
  8. }
  9. }
  10. }
  1. {
  2. "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
  3. "compilerOptions": {
  4. "lib": ["DOM", "DOM.Iterable", "ES2019"],
  5. "isolatedModules": true,
  6. "esModuleInterop": true,
  7. "jsx": "react-jsx",
  8. "resolveJsonModule": true,
  9. "moduleResolution": "node",
  10. "baseUrl": ".",
  11. "noEmit": true,
  12. "paths": {
  13. "~/*": ["./app/*"]
  14. }
  15. }
  16. }

If you are using TypeScript, you also need to create the remix.env.d.ts file in the root of your project with the appropriate global type references.

  1. /// <reference types="@remix-run/dev" />
  2. /// <reference types="@remix-run/node" />

A note about non-standard imports

At this point, you might be able to run your app with no changes. If you are using Create React App or a highly-configured bundler setup, you likely use import to include non-JavaScript modules like stylesheets and images.

Remix does not support most non-standard imports, and we think for good reason. Below is a non-exhaustive list of some of the differences you’ll encounter in Remix, and how to refactor as you migrate.

Asset imports

Many bundlers use plugins to allow importing various assets like images and fonts. These typically come into your component as string representing the filepath of the asset.

  1. import logo from "./logo.png";
  2. export function Logo() {
  3. return <img src={logo} alt="My logo" />;
  4. }

In Remix, this works basically the same way. For assets like fonts that are loaded by a <link> element, you’ll generally import these in a route module and include the filename in an object returned by a links function. See our docs on route links for more information.

SVG imports

Create React App and some other build tools allow you to import SVG files as a React component. This is a common use case for SVG files, but it’s not supported by default in Remix.

  1. // This will not work in Remix!
  2. import MyLogo from "./logo.svg";
  3. export function Logo() {
  4. return <MyLogo />;
  5. }

If you want to use SVG files as React components, you’ll need to first create the components and import them directly. React SVGR is a great toolset that can help you generate these components from the command line or in an online playground if you prefer to copy and paste.

  1. <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor">
  2. <path fill-rule="evenodd" clip-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" />
  3. </svg>
  1. export default function Icon() {
  2. return (
  3. <svg
  4. xmlns="http://www.w3.org/2000/svg"
  5. className="icon"
  6. viewBox="0 0 20 20"
  7. fill="currentColor"
  8. >
  9. <path
  10. fillRule="evenodd"
  11. clipRule="evenodd"
  12. d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z"
  13. />
  14. </svg>
  15. );
  16. }

CSS imports

Create React App and many other build tools support importing CSS in your components in various ways. Remix supports importing regular CSS files along with several popular CSS bundling solutions described below.

In Remix, regular stylesheets can be loaded from route component files. Importing them does not do anything magical with your styles, rather it returns a URL that can be used to load the stylesheet as you see fit. You can render the stylesheet directly in your component or use our links export.

Let’s move our app’s stylesheet and a few other assets to the links function in our root route:

  1. import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
  2. import { Links } from "@remix-run/react";
  3. import App from "./app";
  4. import stylesheetUrl from "./styles.css";
  5. export const links: LinksFunction = () => {
  6. // `links` returns an array of objects whose
  7. // properties map to the `<link />` component props
  8. return [
  9. { rel: "icon", href: "/favicon.ico" },
  10. { rel: "apple-touch-icon", href: "/logo192.png" },
  11. { rel: "manifest", href: "/manifest.json" },
  12. { rel: "stylesheet", href: stylesheetUrl },
  13. ];
  14. };
  15. export default function Root() {
  16. return (
  17. <html lang="en">
  18. <head>
  19. <meta charSet="utf-8" />
  20. <meta
  21. name="viewport"
  22. content="width=device-width, initial-scale=1"
  23. />
  24. <meta name="theme-color" content="#000000" />
  25. <meta
  26. name="description"
  27. content="Web site created using create-react-app"
  28. />
  29. <Links />
  30. <title>React App</title>
  31. </head>
  32. <body>
  33. <App />
  34. </body>
  35. </html>
  36. );
  37. }

You’ll notice on line 32 that we’ve rendered a <Links /> component that replaced all of our individual <link /> components. This is inconsequential if we only ever use links in the root route, but all child routes may export their own links that will also be rendered here. The links function can also return a PageLinkDescriptor object that allows you to prefetch the resources for a page the user is likely to navigate to.

If you currently inject <link /> tags into your page client-side in your existing route components, either directly or via an abstraction like react-helmet, you can stop doing that and instead use the links export. You get to delete a lot of code and possibly a dependency or two!

PostCSS

To enable PostCSS support, set the postcss option to true in remix.config.js. Remix will then automatically process your styles with PostCSS if a postcss.config.js file is present.

  1. /** @type {import('@remix-run/dev').AppConfig} */
  2. module.exports = {
  3. postcss: true,
  4. // ...
  5. };

CSS bundling

Remix has built-in support for CSS Modules, Vanilla Extract and CSS side effect imports. In order to make use of these features, you’ll need to set up CSS bundling in your application.

First, to get access to the generated CSS bundle, install the @remix-run/css-bundle package.

  1. npm install @remix-run/css-bundle

Then, import cssBundleHref and add it to a link descriptor—most likely in root.tsx so that it applies to your entire application.

  1. import { cssBundleHref } from "@remix-run/css-bundle";
  2. import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
  3. export const links: LinksFunction = () => {
  4. return [
  5. ...(cssBundleHref
  6. ? [{ rel: "stylesheet", href: cssBundleHref }]
  7. : []),
  8. // ...
  9. ];
  10. };

See our docs on CSS bundling for more information.

Note: Remix does not currently support Sass/Less processing directly, but you can still run those as a separate process to generate CSS files that can then be imported into your Remix app.

Rendering components in <head>

Just as a <link> is rendered inside your route component and ultimately rendered in your root <Links /> component, your app may use some injection trickery to render additional components in the document <head>. Often this is done to change the document’s <title> or <meta> tags.

Similar to links, each route can also export a meta function that—you guessed it—returns a value responsible for rendering <meta> tags for that route. This is useful because each route often has its own.

The API is slightly different for meta. Instead of an array, it returns an object where the keys represent the meta name attribute (or property in the case of OpenGraph tags) and the value is the content attribute. The object can also accept a title property that renders a <title /> component specifically for that route.

  1. import type { MetaFunction } from "@remix-run/node"; // or cloudflare/deno
  2. export const meta: MetaFunction = () => {
  3. return {
  4. title: "About Us",
  5. "og:title": "About Us",
  6. description: "Doin hoodrat stuff with our friends",
  7. "og:description": "Doin hoodrat stuff with our friends",
  8. "og:image:url": "https://remix.run/og-image.png",
  9. "og:image:alt": "Just doin a bunch of hoodrat stuff",
  10. };
  11. };
  12. export default function About() {
  13. return (
  14. <main>
  15. <h1>About us</h1>
  16. <PageContent />
  17. </main>
  18. );
  19. }

Again—no more weird dances to get meta into your routes from deep in the component tree. Export them at the route level and let the server handle it. ✨

Updating imports

Remix re-exports everything you get from react-router-dom and we recommend that you update your imports to get those modules from @remix-run/react. In many cases, those components are wrapped with additional functionality and features specifically optimized for Remix.

Before:

  1. import { Link, Outlet } from "react-router-dom";

After:

  1. import { Link, Outlet } from "@remix-run/react";

Final Thoughts

While we’ve done our best to provide a comprehensive migration guide, it’s important to note that we built Remix from the ground up with a few key principles that differ significantly from how many React apps are currently built. While your app will likely run at this point, as you dig through our docs and explore our APIs, we think you’ll be able to drastically reduce the complexity of your code and improve the end-user experience of your app. It might take a bit of time to get there, but you can eat that elephant one bite at a time.

Now then, go off and remix your app. We think you’ll like what you build along the way! 💿

Further reading