Migrating a Create-React-App project to Remix

Leejjon
15 min readDec 10, 2023

Create-react-app has been unmaintained for over two years. I’ve been doing research for a while now what I should use to run and build my React App instead.

UPDATE: This guide converts the app from a SPA (Single page app) hosted on an Express.js server to Remix which has a default server included. By default Remix does some server side rendering. Which is great for SEO and performance. If you want to ditch Create-React-App but still want to output static HTML and JavaScript so you can host it on a CDN, Remix now has SPA mode.

My “old” stack

Read this post if you want more details on my existing stack. TLDR I have been using:

  • Node.js — JavaScript runtime environment
  • Express.js — Fast, unopinionated, minimalist web framework for Node.js
  • TypeScript — TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale.
  • Create-react-app — Set up a React web app by running one command.
  • Webpack — A bundler.
  • App Engine — A service to easily run Node.js (or other) applications on Google Cloud Platform

I selected this stack in 2018 with the knowledge that I had.

Create-react-app eject

CRA (the abbreviation for create-react-app) was the recommended way according to the official documentation to bootstrap simple React projects. CRA projects have scripts in the package.json to build and run your react app:

"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}

For running and building React the CRA scripts used Webpack, Babel and more under the hood. As a CRA user you were not supposed to alter any of those settings. This made sure the team that maintained CRA could update their Webpack / Babel configuration and users could follow easily by updating the version of the react-scripts dependency.

For those that somehow needed to alter their Webpack / Babel settings, CRA offered an eject script. This would generate the webpack and babel configuration files that CRA used along with some scripts to use them. After ejecting, the user would have to update and maintain those by himself.

You can eject by running npm run eject on a CRA project.

It generates a bunch of scripts and dependencies in your project. Here is a full list of files generated after eject. I will believe the maintainers had a good reason for discontinuing CRA, so I am not going to go against their take and maintain all of that by myself!

Continuing to build on my React Router configuration

My current React application bootstrapped with create-react-app is using React Router for the routing in my Single Page App. I’m mostly very happy with my routes defined in JSX.

By following Michael Jackson (the co-author of React Router, not the King of Pop) on X and conferences I was introduced to the Remix framework. Remix is a full stack framework for React applications. It is built on top of the foundations of React Router.

I hoped migrating to Remix would be a smooth transition with React Router already handling my routes.

One of the advantages of using Remix is that it can run on multiple HTTP servers. It’s not even tied to Node.js. You can read more on all possibilities in the Remix documentation. When looking at this I think Remix gives me more flexibility if I ever need to switch to another cloud provider.

Why don’t you use Next.js like everybody is doing now?

Next.js would definitely be a good base for my application. I have worked for about two years on a Next.js project. If you deploy it on Vercel it really works like a charm.

The main reasons were:

  • The struggle I’ve had to run Next.js outside of Vercel.
  • I already know Next.js, and want to try something new.
  • Because Kent C Dodds says it follows web standards more closely.

The reason I even used an Express.js server

I had an Expres.js application running on Google App Engine. My first thought was to serve Remix from this server and use the Express.js adapter for Remix. But the Expess.js template for Remix didn’t have the server code in TypeScript. I did try to convert it, but ran into trouble with HMR (Webpack and Remix in one project required to have two tsconfig.json files, and that makes the setup complex).

I was using Express.js for two things:

  • To make sure every URL (example.com/page) would point to the index.html of the single page web app built with create-react-app. Then React Router would load the right content on the client.
  • Set the correct page title, page description and other meta tags on the server side so that the Google bot would be able to index my web app properly on Google (Google bots often fail to render JavaScript).

The book Full Stack Development with Remix by Andre Landgraf (this isn’t an ad, I just thought this book was really good) thaught me that above points can be done in Remix directly, without needing Express.js.

So we’re ditching Express.js and running the default Remix template (for Node.js).

A test CRA + React Router project to migrate

Lets quickly create a TypeScript project generated by Create-React-App that uses React Router for routing. Here are the bash commands to do so (or clone it from here):

# Create the 
npx create-react-app cra-with-router --template typescript
cd cra-with-router
npm i react-router-dom @types/react-router-dom react-helmet-async
mkdir src/pages
touch src/pages/Home.tsx
touch src/pages/News.tsx
touch src/pages/About.tsx

In Home.tsx put the following code:

import {Helmet} from "react-helmet-async";

function Home() {
const title = "Home";
const description = "Home sweet home.";
return (
<>
<Helmet>
<title>{title}</title>
<meta name="description" content={description}/>
<meta property="og:title" content={title}/>
<meta property="og:description" content={description}/>
</Helmet>
<div>{description}</div>
</>
);
}

export default Home;

You might not have heard about the react-helmet-async package. It’s a fork of the original react-helmet package that doesn’t receive updates anymore. I used it to make sure the page title and meta tags are correct for every page and that these are updated when a user goes to another page.

In News.tsx put the following code:

import {Helmet} from "react-helmet-async";

function News() {
const title = "News";
const description = "The latest news.";
return (
<>
<Helmet>
<title>{title}</title>
<meta name="description" content={description}/>
<meta property="og:title" content={title}/>
<meta property="og:description" content={description}/>
</Helmet>
<div>The latest news.</div>
</>
);
}

export default News;

In About.tsx put the following code:

import {Helmet} from "react-helmet-async";

function About() {
const title = "About";
const description = "About us.";
return (
<>
<Helmet>
<title>{title}</title>
<meta name="description" content={description}/>
<meta property="og:title" content={title}/>
<meta property="og:description" content={description}/>
</Helmet>
<div>{description}</div>
</>
);
}

export default About;

And finally replace the code in the App.tsx with:

import React, {lazy, Suspense} from 'react';
import {Link, Route, Routes} from 'react-router-dom';
import {BrowserRouter} from 'react-router-dom';
import './App.css';

const Home = lazy(() => import("./pages/Home"));
const News = lazy(() => import("./pages/News"));
const About = lazy(() => import("./pages/About"));

function App() {
return (
<div className="App">
<Suspense fallback={<Loader/>}>
<BrowserRouter>
<Link id="linkToHome" to="/">
Home
</Link><br/>
<Link id="linkToNews" to="/news">
News
</Link><br/>
<Link id="linkToAbout" to="/about">
About
</Link>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/news" element={<News/>}/>
<Route path="/about" element={<About/>}/>
</Routes>
</BrowserRouter>
</Suspense>
</div>
);
}

function Loader() {
return (
<div>Loading...</div>
);
}

export default App;

Now run the app with: npm run start and visit localhost:3000 in your browser. The pages are initialized lazily using React Suspense. This means we will only load it when you visit it the first time. After that you can navigate between these three pages without the browser doing any calls to the server.

You can see the page title and meta title and descriptions are filled properly by react-helmet-async.

Now even though react-helmet-async sets the page title and description properly, the Google search bot would not index the CRA version of my app properly.

I solved it in the Express.js like this guide explains. Which isn’t ideal because it means every time you add a page in your React application you need to update your Express.js server too.

Building the same in Remix

Generate a Remix project:

npx create-remix@latest

You’ll get a project that looks like this:

Run it with npm run dev and you can see the app work in your browser:

If you look into the root.tsx you’ll notice it doesn’t have any routes. One of my mistakes was that I started to add routes in there like we did in the cra-with-routing project, but you should not. Remix generates the route configurations by looking at the files in the routes folder.

If we want to see what the routes look like of this current Remix app, run:

npx remix routes

The output will be:

<Routes>
<Route file="root.tsx">
<Route index file="routes/_index.tsx" />
</Route>
</Routes>

That looks a bit like React Router code.

Moving our content over to the Remix app

Paste the following JSX code (basically the Home.tsx of our previous cra-with-routing project without the helmet part) in the _index.tsx file (which is the default component):

export default function Index() {
return (
<div>Home sweet home.</div>
);
}

If you look in your browser, it is now showing our contents.

Now copy and paste the About.tsx and News.tsx files to the routes folder in our the new remix-with-routing project. You can remove the <Helmet> tag. These routes will start working (without even restarting the dev server):

If we run the npx remix routes command again, you can see that the routes now look more like what we had in the cra-with-routing project:

<Routes>
<Route file="root.tsx">
<Route index file="routes/_index.tsx" />
<Route path="About" file="routes/About.tsx" />
<Route path="News" file="routes/News.tsx" />
</Route>
</Routes>

Adding the navigation links

In Remix you don’t use the React Router’s Link components. Remix has it’s own Link component, which you can use to add navigation links just above the <Outlet/> element:

import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node";
import {
Link,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Link to="/">Home</Link>|
<Link to="/news">News</Link>|
<Link to="/about">About</Link>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

The <Outlet> element is important. It renders whatever component in the routes folder matches the current route. If you delete it none of the components in the routes folder will be rendered.

With these changes, the Remix app now looks the same as the application made with CRA:

Adding correct page titles

Unlike fixing the page titles and meta tags in Express.js, in Remix you can simply add a MetaFunction to the pages.

Home.tsx:

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

const title = "Home";
const description = "Home sweet home."

export const meta: MetaFunction = () => {
return [
{ title: title },
{ name: "description", content: description },
];
};

export default function Index() {
return (
<div>{description}</div>
);
}

News.tsx:

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

const title = "News";
const description = "The latest news."

export const meta: MetaFunction = () => {
return [
{ title: title },
{ name: "description", content: description },
];
};

function News() {
return (
<div>{description}</div>
);
}

export default News;

About.tsx:

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

const title = "About";
const description = "About us."

export const meta: MetaFunction = () => {
return [
{ title: title },
{ name: "description", content: description },
];
};

function About() {
return (
<div>{description}</div>
);
}

export default About;

And now we have titles and descriptions that are rendered on the server:

You can test that the meta tags are there before React is being initialized with curl:

curl localhost:3000/news

You should see the correct title.

The MetaFunction returns an array, so you can easily add as many tags to the head element as you like.

Using translations in Remix

My original project created with Create-React-App had translations set up using the react-i18next library. My web app had two domains, a Dutch one and an English one. Based on the domain that the user was requesting from, I’d load the correct translations.

Let’s add this to our CRA project (or download this branch) to show how we can migrate this functionality to Remix. In the cra-with-router project, run:

npm i i18next react-i18next

Now create a file called i18n.ts and put the following content in:

import i18n from "i18next";
import {initReactI18next} from "react-i18next";

// English is loaded unless the user comes from the Dutch domain.
const locale = window.location.hostname.endsWith('mysite.nl') ? 'nl' : 'en';

export const resources = {
en: {
translation: {
DESCRIPTION_ABOUT: "About us.",
DESCRIPTION_HOME: "Home sweet home.",
DESCRIPTION_NEWS: "The latest news.",
LOADING: "Loading...",
TITLE_ABOUT: "About",
TITLE_HOME: "Home",
TITLE_NEWS: "News",
}
},
nl: {
translation: {
DESCRIPTION_ABOUT: "Over ons.",
DESCRIPTION_HOME: "Fijn thuis.",
DESCRIPTION_NEWS: "Het laatste niews.",
LOADING: "Laden...",
TITLE_ABOUT: "Over ons",
TITLE_HOME: "Thuis",
TITLE_NEWS: "News",
}
}
}

i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
lng: locale,
returnNull: false,
keySeparator: false, // we do not use keys in form messages.welcome
interpolation: {
escapeValue: false // react already safes from xss
}
});

In the index.tsx file, add this import to make sure the above code is executed:

import './locales/i18n';

Now in the App.tsx we can use the useTranslation hook to grab the correct translation on runtime.

import React, {lazy, Suspense} from 'react';
import {Link, Route, Routes} from 'react-router-dom';
import {BrowserRouter} from 'react-router-dom';
import {HelmetProvider} from "react-helmet-async";
import './App.css';
import {useTranslation} from "react-i18next";

const Home = lazy(() => import("./pages/Home"));
const News = lazy(() => import("./pages/News"));
const About = lazy(() => import("./pages/About"));

function App() {
const {t} = useTranslation();
return (
<HelmetProvider>
<div className="App">
<Suspense fallback={<Loader/>}>
<BrowserRouter>
<Link id="linkToHome" to="/">
{t("TITLE_HOME")}
</Link><br/>
<Link id="linkToNews" to="/news">
{t("TITLE_NEWS")}
</Link><br/>
<Link id="linkToAbout" to="/about">
{t("TITLE_ABOUT")}
</Link>
<Routes>
<Route path="/" element={<Home/>}/>
<Route path="/news" element={<News/>}/>
<Route path="/about" element={<About/>}/>
</Routes>
</BrowserRouter>
</Suspense>
</div>
</HelmetProvider>
);
}

function Loader() {
const {t} = useTranslation();
return (
<div>{t("LOADING")}</div>
);
}

export default App;

In the Home.tsx, News.tsx and About.tsx pages you can also use the useTranslation hook:

import {Helmet} from "react-helmet-async";
import {useTranslation} from "react-i18next";

function Home() {
const {t} = useTranslation();
return (
<>
<Helmet>
<title>{t("TITLE_HOME")}</title>
<meta name="description" content={t("DESCRIPTION_HOME")}/>
<meta property="og:title" content={t("TITLE_HOME")}/>
<meta property="og:description" content={t("DESCRIPTION_HOME")}/>
</Helmet>
<div>{t("DESCRIPTION_HOME")}</div>
</>
);
}

export default Home;

You can run the project with npm start and see the translations work.

The downside of these translations in React is that when a Web crawler (like the Googlebot) downloads the page and sometimes doesn’t render JavaScript.

You can try to download your page with curl to see what a Web crawler that doesn’t support JavaScript would see and index.

As you can see, there are no translations to be found in the index.html

Using the i18next library in Remix

In the CRA project, we had one translations file for all React components that was downloaded on the first page visit. Then the useTranslation hook would be used to load and use translations in your React component.

Since Remix renders so much on the server, you can’t just use the useTranslation hook in Remix components.

Sergioxda has made the remix-i18next package (that uses i18n and react-i18next under the hood) that can get the useTranslation hook to work in Remix components.

Remix-i18next also specifies how to put translations in components that are rendered on the server side:

After some of trial and error I figured out how to use these translations from loaders, in both the page and the MetaFunction. See this PR to see my attempt at using remix-i18next for translations in my server side rendered components.

I felt that three depencencies (i18n, react-i18next and remix-i18next

Building our own solution for translations in loaders

Create a file called i18n.ts in which you put the actual translations (a little bit like in the Create-React-App project:


type TranslationPairs = {[key: string]: string};

export const resources: { [language: string]: { translation: TranslationPairs } } = {
en: {
translation: {
DESCRIPTION_ABOUT: "About us.",
DESCRIPTION_HOME: "Home sweet home.",
DESCRIPTION_NEWS: "The latest news.",
LOADING: "Loading...",
TITLE_ABOUT: "About",
TITLE_HOME: "Home",
TITLE_NEWS: "News",
}
},
nl: {
translation: {
DESCRIPTION_ABOUT: "Over ons.",
DESCRIPTION_HOME: "Fijn thuis.",
DESCRIPTION_NEWS: "Het laatste niews.",
LOADING: "Laden...",
TITLE_ABOUT: "Over ons",
TITLE_HOME: "Thuis",
TITLE_NEWS: "News",
}
}
};

export function getCountryCode(request: Request) {
const host = request.headers.get("Host");
return host && host.endsWith("mysite.nl") ? "nl" : "en";
}

function translate(countryCode: string, key: string) {
for (const country in resources) {
if (country === countryCode) {
const translations = resources[countryCode].translation;
for (const translationKey in translations) {
if (translationKey == key) {
return translations[translationKey];
}
}
return `Translation key ${key} is missing.`
}
}
return `${countryCode} is not a supported language.`;
}

export function getTranslator(countryCode: string) {
return (key: string) => translate(countryCode, key);
}

Passing translations from a loader

Now we’ve set up all this configuration, let’s use translations for the links in our root.tsx page. To use them we have to add a loader:

import { cssBundleHref } from "@remix-run/css-bundle";
import {json, type LinksFunction, type LoaderFunctionArgs} from "@remix-run/node";
import {
Link,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import {getCountryCode, getTranslator} from "~/i18n";

export async function loader({request}: LoaderFunctionArgs) {
const t = getTranslator(getCountryCode(request));
const TITLE_ABOUT = t("TITLE_ABOUT");
const TITLE_HOME = t("TITLE_HOME");
const TITLE_NEWS = t("TITLE_NEWS");
const headers = { "Cache-Control": "max-age=86400" }; // One day

return json({
TITLE_ABOUT, TITLE_HOME, TITLE_NEWS
}, {headers});
}

export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];

export default function App() {
const {
TITLE_ABOUT,
TITLE_HOME,
TITLE_NEWS
} = useLoaderData<typeof loader>();

return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Link to="/">{TITLE_HOME}</Link>|
<Link to="/news">{TITLE_NEWS}</Link>|
<Link to="/about">{TITLE_ABOUT}</Link>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

You might notice that if you run this code, the translations are downloaded in a GET call (due to the loader mechanism):

I added a Cache-Control header in the loader that tells the browser to cache the GET call for a day. This should reduce traffic a lot.

Let’s also add a loader in the pages (_index.tsx, About.tsx and News.tsx) page to let the pages use the translations in their content and meta headers.

_index.tsx:

import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {getCountryCode, getTranslator} from "~/i18n";

export async function loader({request}: LoaderFunctionArgs) {
const t = getTranslator(getCountryCode(request));
const TITLE_HOME = t("TITLE_HOME");
const DESCRIPTION_HOME = t("DESCRIPTION_HOME");

const headers = { "Cache-Control": "max-age=86400" }; // One day

return json({
DESCRIPTION_HOME, TITLE_HOME
}, {headers});
}

export const meta: MetaFunction<typeof loader> = ({data}) => {
if (data) {
const {DESCRIPTION_HOME, TITLE_HOME} = data;
return [
{ title: TITLE_HOME },
{ name: "description", content: DESCRIPTION_HOME },
];
} else {
return [
{ title: "Couldn't load the translations." }
];
}
};

export default function Index() {
const {DESCRIPTION_HOME} = useLoaderData<typeof loader>();
return (
<div>{DESCRIPTION_HOME}</div>
);
}

About.tsx:

import { json, type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {getCountryCode, getTranslator} from "~/i18n";

export async function loader({ request }: LoaderFunctionArgs) {
console.log(new URL(request.url).pathname);
const t = getTranslator(getCountryCode(request));
const TITLE_ABOUT = t("TITLE_ABOUT");
const DESCRIPTION_ABOUT = t("DESCRIPTION_ABOUT");

const headers = { "Cache-Control": "max-age=86400" }; // One day

return json({
DESCRIPTION_ABOUT, TITLE_ABOUT
}, { headers });
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (data) {
const {DESCRIPTION_ABOUT, TITLE_ABOUT} = data;
return [
{ title: TITLE_ABOUT },
{ name: "description", content: DESCRIPTION_ABOUT },
];
} else {
return [
{ title: "Couldn't load the translations." }
];
}
};

function About() {
const {DESCRIPTION_ABOUT} = useLoaderData<typeof loader>();
return (
<div>{DESCRIPTION_ABOUT}</div>
);
}

export default About;

News.tsx:

import {json, type LoaderFunctionArgs, type MetaFunction} from "@remix-run/node";
import {useLoaderData} from "@remix-run/react";
import {getCountryCode, getTranslator} from "~/i18n";

export async function loader({request}: LoaderFunctionArgs) {
const t = getTranslator(getCountryCode(request));
const TITLE_NEWS = t("TITLE_NEWS");
const DESCRIPTION_NEWS = t("DESCRIPTION_NEWS");

const headers = {"Cache-Control": "max-age=86400"}; // One day

return json({
DESCRIPTION_NEWS, TITLE_NEWS
}, {headers});
}

export const meta: MetaFunction<typeof loader> = ({data}) => {
if (data) {
const {TITLE_NEWS, DESCRIPTION_NEWS} = data;
return [
{title: TITLE_NEWS},
{name: "description", content: DESCRIPTION_NEWS},
];
} else {
return [
{title: "Couldn't load the translations."}
];
}
};

function News() {
const {DESCRIPTION_NEWS} = useLoaderData<typeof loader>();
return (
<div>{DESCRIPTION_NEWS}</div>
);
}

export default News;

If you now run the remix project with npm run dev you will notice the translations will be correct on all pages.

The GET calls to obtain the translations will be cached properly.
The response of the GET call.

Done! I’d love to see feedback on my translations approach.

Follow me for more!

The final source code can be found in this GitHub project. If you enjoyed this blog post, please follow me here on Medium, X or LinkedIN.

--

--

Leejjon

Java/TypeScript Developer. Interested in web/mobile/backend/database/cloud. Freelancing, only interested in job offers from employers directly. No middle men.