Maybe I’m strange, but I always feel a little sad when an app or framework that has been a regular part of my workflow goes to the great repository in the sky. That was certainly my initial reaction after hearing that Create React App (or CRA to its friends) was being deprecated. My second reaction: now what do I use?
Luckily, the React team put up a list of possible CRA replacements as part of the deprecation announcement, and in this post I’m going to focus on the first option: Next.js. Why? IMO it offers the most features for going above and beyond just a simple migration. But we’ll get into that later.
Comparing CRA with Next.js
If you’re new to Next.js, here’s a quick breakdown of its 1:1 feature equivalents for CRA users, as well as potential server-side functionality:
If you used this in CRA | Client-side equivalent in Next.js | Server-side possibility in Next.js |
---|---|---|
npx create-react-app |
npx create-next-app |
npx create-next-app (same, supports both modes) |
--template typescript |
npx create-next-app --ts |
Full TypeScript setup for both client and server components |
react-router |
File-based routing in pages/
|
App Router with nested layouts and server components |
react-helmet |
next/head |
Dynamic updates in server components |
Static file serving at public/
|
Same – uses public/
|
next/image with built-in image optimization |
Fetch data in useEffect
|
useSWR or fetch() in a client component |
Fetch data directly in server component, RSC fetch |
Component state with useState
|
useState in client components ('use client' ) |
Server-rendered props via RSC + cookies/session context |
Local dev server with Webpack | Webpack (built-in) | Webpack + incremental adoption of Turbopack |
Environment variables in .env
|
Same, plus bundle for the browser with NEXT_PUBLIC_ prefix |
Same, plus runtime env vars via Docker image |
BYO backend/server | Requires separate Node server | Built-in API routes (/api/ ) |
Testing with Jest | User-installed Jest or Vitest | User-installed Jest or Vitest (no native test runner) |
CSS with plain files or modules | Global CSS and CSS Modules supported, built-in code-splitting | Full support for Tailwind, Sass, PostCSS, and scoped CSS |
The initial setup and much of the configuration of Next.js will seem familiar to CRA users. Like CRA, Next.js has a one-line init
command: npx create-next-app
. For TypeScript users, adding the --ts
flag means your project is automatically configured for TypeScript, much like the --template typescript
flag for CRA. Both frameworks ship with Webpack, but Next.js also includes Turbopack, a newer and in most cases more performant bundler from the Next.js team. For testing, CRA uses Jest under the hood, while Next.js leaves it up to the user to choose a testing library.
The main differences between the two frameworks mainly come down to the features Next.js offers that aren’t available in CRA. These can be divided into two categories: performance improvements and developer experience (DX) improvements.
Performance improvements:
- Server-Side Rendering (SSR) for faster initial loads
- Image optimization for responsive, lazy-loaded images
- Built-in code splitting by default at the page level
- Edge and serverless function support
DX improvements:
- File-based routing, so no need to manually configure routes
- API routes – build backend logic right in your app
- Built-in support for layouts and nested routing in the App Router
- Next.js Middleware makes A/B testing, i18n, etc. much easier to implement
Now that we’ve seen how Next.js compares feature-for-feature with CRA, the next question is: why make the switch at all?
Why migrate?
If you have a legacy app or side project that hasn’t been updated in years, you may be wondering why bother migrating if the CRA setup is working? For deployed apps, the answer is vulnerabilities: at some point, you or dependabot will need to update a library with high severity vulnerabilities, and will likely run into the dreaded Conflicting peer dependency
error. As CRA’s dependencies are no longer being updated, that error is more likely to occur the longer you wait.
Additionally, you’ll need to migrate if you want to get newer versions of core libraries such as TypeScript or React. While React 19 currently works with CRA, TypeScript is sadly stuck at version 4, meaning you won’t get the speed or size improvements or expanded ESM support of version 5.
But the best reason to migrate is to take advantage of Next.js server-side features and built-in optimizations. So, let’s explore some migration strategies next.
Migrating an existing CRA app to Next.js with client-side features only
If you’re ready to migrate an existing CRA app to Next.js but want to start with a 1:1 move that keeps your app functioning as a single-page client-side app (SPA), the pages/
directory is the best place to begin. This setup allows you to preserve familiar patterns while getting immediate benefits like built-in routing.
Let’s walk through migrating a simple component; in this case, a minimal login UI from a CRA app:
// src/App.tsx
import { useState } from 'react';
export default function App() {
const [user, setUser] = useState<string | null>(null);
const handleLogin = async () => {
const res = await fetch('/login', { method: 'POST' });
const data = await res.json();
setUser(data.name);
};
return (
<div>
{user ? (
<p>Welcome, {user}!</p>
) : (
<button onClick={handleLogin}>Log In</button>
)}
</div>
);
}
In Next.js, we would first create pages/index.tsx
, to replace src/App.tsx
, then move the component mostly as-is to the new file:
// pages/index.tsx
import { useState } from 'react';
export default function Home() {
const [user, setUser] = useState<string | null>(null);
const handleLogin = async () => {
const res = await fetch('/api/login', { method: 'POST' });
const data = await res.json();
setUser(data.name);
};
return (
<main>
{user ? (
<p>Welcome, {user}!</p>
) : (
<button onClick={handleLogin}>Log In</button>
)}
</main>
);
}
This is still a purely client-side component. You get immediate access to Next.js features like routing without giving up control over how your app behaves. To complete the example, we also need to add a login endpoint under pages/api/
:
// pages/api/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const user = { name: 'Jane Doe' };
res.status(200).json(user);
}
Pretty straightforward, with minimal code changes. Of course, in a large codebase, things can get more complex, so if you want to see what a migration like this looks like in practice, see this commit of a working example app. If you have an existing CRA app that you’re ready to migrate to Next.js, you can follow this step-by-step migration guide.
The migration guide and commit linked above offer examples of straightforward, 1:1 SPA migrations. But to really take advantage of Next.js, let’s see what a migration to using server-side rendering (SSR) looks like.
Migrate a SPA to React Server Components
While migrations are often done out of necessity, they can also serve as an opportunity to make significant performance improvements and modernize a legacy app’s architecture. Refactoring an SPA to use server components will reduce your client-side JavaScript bundle size, improving initial load times and your users’ experience. Server components also allow for resolving authentication state server-side, which has the added benefit of being more secure while also reducing load times. And the final bonus: server-rendered content is better for search engines, if SEO is important to you. React Server Components go one step further than SSR, allowing parts of the component tree to render only on the server, never reaching the client as JavaScript.
What does this look like in practice? Let’s return to our authentication example, now migrated to the App Router. In a server component like app/page.tsx
, we use cookies()
to access login state directly from the server:
// app/page.tsx
import { cookies } from 'next/headers';
import { LoginButton } from './LoginButton';
export default function Home() {
const user = cookies().get('user')?.value;
return (
<main>
{user ? <p>Welcome, {user}!</p> : >}
</main>
);
}
Only the LoginButton
component remains client-side. This keeps the interactive part of the app lightweight, while moving the auth check to the server where it belongs:
// app/api/login/route.ts
import { NextResponse } from 'next/server';
export async function POST() {
const user = { name: 'Jane Doe' };
const res = NextResponse.json(user);
res.cookies.set('user', user.name, {
httpOnly: true,
maxAge: 60 * 60,
});
return res;
}
This structure allows the app to render personalized content without requiring a round trip to the client to determine the user’s session. It’s a small change that brings meaningful performance and security improvements. To see what this looks like in an existing codebase, this commit from the same repository referenced earlier shows a migration from client components to using server-side API routes and middleware.
Stretch goal: implement Partial Prerendering for the best of both worlds
If you want to go even further into Next.js’s experimental features, you can implement Partial Prerendering (PPR) in the same component. While React Server Components are great for applications that need real-time dynamic data, and static generation is great for infrequently updated data, such as content from a CMS, PPR is a new feature that gives you the best of both. PPR uses React Suspense to separate dynamic content from static content, allowing Next.js to pre-render the static content at build time, while dynamic content is streamed at runtime from the server.
Here’s an example of our auth component using PPR. First, we’d create a dynamic user component and skeleton:
// app/user.tsx
import { cookies } from 'next/headers';
export async function User() {
const name = cookies().get('user')?.value;
return <p>Welcome, {name ?? 'Guest'}!</p>;
}
export function UserSkeleton() {
return <p>Loading user...</p>;
}
Then, using Suspense
, we designate the dynamic part of our page while allowing the remainder to load as static content:
// app/page.tsx
import { Suspense } from 'react';
import { User, UserSkeleton } from './user';
export const experimental_ppr = true; // This enables PPR for the entire app/ route
export default function HomePage() {
return (
<main>
<h1>This content is statically prerendered</h1>
<Suspense fallback={<UserSkeleton />}>
<User />
</Suspense>
</main>
);
}
With just a few tweaks, our legacy CRA auth code has evolved into a modern, flexible component that plays nicely with both static and dynamic rendering.
So yes, it’s always a little sad to say goodbye to an old tool, especially one that’s been part of your team’s routine. But as we’ve seen, moving from CRA to Next.js doesn’t mean starting over. It just means migrating to a more flexible, more capable way of building React apps.