I recently moved a significant codebase from Create-React-App (CRA for short) to Next and thought I would share my experience, because believe me or not, it was quite a journey (and not necessarily a pleasant one).

There are plenty reasons why you might want to move to Next from a CRA app. It provides server-side rendering (SSR), and even incremental static regeneration (ISR) when hosted on Vercel. It’s an encompassing framework with built-in routing, image optimization, development environment, and more.

This post is a high-level walkthrough of things to deal with to finalized the migration from CRA to Next. Here’s what we’ll cover:

HTML boilerplate

CRA uses an index.html file in the public folder to configure the HTML document surrounding the app. Next handles everything in React via the _document.js file, so it needs to be moved manually. Fortunately, it’s relatively easy to do, and Next documentation provides some pointers.

Head content

For custom head management on a per-page basis, Next comes with its own solution, next/head, while CRA doesn’t. The usual suspects are react-helmet (or its clean version, react-helmet-async) or the more recent hoofd. Either way, I’d recommend abstracting usages of the library to certain components or hooks, so there is only one place to update when switching to Next.

For instance, instead of importing Head from react-helmet in every page, import your own Head component which wraps the react-helmet one. This way, you can update the implementation detail to make it work with Next without having to touch any other component.

Routing

CRA does not have built-in routing capability, so is often coupled with react-router-dom. You’d usually have a router component which declares all your routes, and for each route, which component to render. For instance:

import { BrowserRouter, Route, Switch } from 'react-router-dom'
import PostPage from '../PostPage'
import HomePage from '../HomePage'

const Router = () => (
  <BrowserRouter>
    <Switch>
      <Route path='/'>
        <HomePage />
      </Route>
      <Route path='/post/:slug'>
        <PostPage />
      </Route>
    </Switch>
  </BrowserRouter>
)

export default Router

Next comes with its own router. More than that: the routing is inferred by the pages folder structure, so there is no router/routes declaration per se. To move that to Next, you would have to create pages/index.js and pages/post/[slug]/index.js, which would look like this:

// pages/index.js
import HomePage from '../components/HomePage'

export default HomePage
// pages/post/[slug]/index.js
import PostPage from '../../../components/PostPage'

export async function getStaticPaths() {
  // If you can compute possible paths ahead of time, feel free to, but you
  // shouldn’t need to do it to complete the migration to Next.
  return { paths: [], fallback: true }
}

export async function getStaticProps(context) {
  // If you want to resolve the whole post data from the slug at build time
  // instead of runtime, feel free to, but you shouldn’t need to do it to
  // complete the migration to Next.
  return { props: { slug: context.params.slug } }
}

export default PostPage

Then in the PostPage component, instead of reading the slug from the router with useRouteMatch from react-router-dom, you’d expect it to come from the props. You could handle both ways like this:

const match = useRouteMatch()
const slug = props.slug || match.params.slug

Beyond the route definition itself, I think a healthy way to migrate that part is to abstract away anything about the router into components and hooks, so it’s just a matter of updating these parts when switching over to Next. For instance, have a link component which wraps Link from react-router-dom, so it’s just matter of updating that component with next/link. Same thing for useRouter and the like.

Note that Next gives some interesting pointers to migrate from react-router.

Code splitting

There again, Next has a solution for manual code-splitting, next/dynamic, while CRA doesn’t. The industry standard—as far as I can tell—is @loadable/component (also implied by Next docs). Both libraries work basically the same though, so the migration should be a few search-and-replace away:

- import loadable from '@loadable/component'
+ import dynamic from 'next/dynamic'

- const MyComponent = loadable(() => import('./MyComponent'))
+ const MyComponent = dynamic(() => import('./MyComponent'))

Styling

CRA has native support for plain CSS. That means you can import a CSS file inside a React component, and CRA will bundle CSS seamlessly. Unfortunately, Next does not beyond global stylesheets. The only place where Next allows importing stylesheets is in the _app.js. So if your codebase uses CSS files all over, you’re in for a painful migration (which is basically what the docs say as well).

The easy-but-dirty way out is to import all your CSS files within _app.js, but that kind of breaks separation of concerns since your components are no longer responsible for their own styles. If you end up deleting a component, you need to remember to delete its imported styles in _app.js. Not great overall.

A better approach would be to do a proper migration. Fortunately, both systems support CSS modules, so one approach might be to manually convert every CSS file to a CSS module. Another approach would be to move the styling layer to a CSS-in-JS solution such as styled-components, Fela, or whatever floats your boat. Either way, that’s going to be a manual migration and a cumbersome one. By far the hardest part.

CSR/SSR

Because CRA doesn’t have server-side rendering (SSR) and only uses client-side rendering (CSR), it’s easy to have authored code that won’t work in Next (during pre-rendering). For instance, accessing browser APIs in the render (such as window, localStorage and the like), or initializing states with client-specific info instead of doing so on mount.

For this part, an intimate knowledge of the codebase will help making things SSR-friendly. It should be relatively easy to do, and a good test suite will help spot cases where Next fails to pre-render a page. A more brutalist approach is to run next build and see where it fails.

Linting

Both Next and CRA come with integrated linting as part of the development environment and the build step. Unfortunately, the configuration is not quite the same. Fortunately, the CRA linting is a bit more strict than Next, so it shouldn’t be too difficult to migrate. I suspect the other way around to be more complex.

You might want to turn of the @next/next/no-img-element rule though, because it expects every image to be authored with next/image, which a) seems awfully dogmatic and b) is unrealistic for the migration.

{
  "extends": "next",
  "rules": {
    "@next/next/no-img-element": "off"
  }
}

Running both systems

One thing I realized only once I was done (insert sad face emoji) is that you could actually run both systems on the same codebase with minimal effort if you cannot one-shot your migration.

CRA uses a single entry point (usually src/index.js), while Next relies on the pages directory, so there is no conflict there. CRA will ignore pages, and Next will ignore the entry file.

If you abstracted into hooks and components everything about routing and head management, you can use an environment variable within said components to use the right libraries. Small proof of concept (not tested, please tread carefully):

import NextLink from 'next/link'
import { Link as RRLink } from 'react-router-dom'

// See: https://nextjs.org/docs/basic-features/environment-variables
// See: https://create-react-app.dev/docs/adding-custom-environment-variables
const FRAMEWORK =
  process.env.NEXT_PUBLIC_FRAMEWORK || process.env.REACT_APP_FRAMEWORK

const Link = props => {
  return FRAMEWORK === 'next' ? (
    <NextLink href={props.to} passHref>
      <a>{props.children}</a>
    </NextLink>
  ) : (
    <RRLink to={props.to}>{props.children}</RRLink>
  )
}

export default Link

This way, you can run REACT_APP_FRAMEWORK=cra react-scripts build and deploy that in production while you slowly migrate your codebase to Next. And you can do staging/beta builds with NEXT_PUBLIC_FRAMEWORK=next next build until you’re happy to put that live.

If you had a custom ESLint configuration for CRA, you might need to make the file a JavaScript file instead of JSON, and pass and use that environment variable to it as well so you can pick the right configuration.

Wrapping up

I’m not going to lie: this will take time and effort and will not be painless. While both frameworks share a lot of similarities, they are also fundamentally different in the way they approach rendering (which is kind of the benefit of Next), so a lot of things will have to be updated. The most annoying part definitely is the CSS migration, if your CRA app uses plain CSS.

Be sure to read the Next migration from CRA guide as it provides a lot of helpful information on how to move from one system to the other that I haven’t covered in this article.

But with careful planning and incremental work while running both systems on the same code base (one for staging, one for production) until the migration is over, I’d say this is something that’s doable, especially for a team of people. And the results are rewarding, so that’s nice.