Type something to search...
Next.js Migrating from Vite

Next.js Migrating from Vite

Vite earned its popularity for a good reason: it's fast, it has almost no configuration ceremony, and npm create vite gets you a working React app in under a minute. If you've built a real product on top of it, though, you eventually run into the ceiling that every pure client-side single-page application hits — a blank white screen while the browser downloads and parses your entire JavaScript bundle, sequential request waterfalls once your components start fetching their own data, and no good story for server rendering without bolting on a separate framework.

Next.js solves those problems natively, but "migrate to Next.js" can sound like a rewrite. It isn't, and that's the point of this guide. The migration path documented here is deliberately conservative: you keep your existing router, your existing components, and your existing app structure exactly as they are, and you get a working Next.js app in under an hour. Only after that foundation is in place do you start adopting the parts of Next.js that actually solve the problems Vite couldn't — and you do that incrementally, at your own pace, one route at a time.

Why This Migration Is Worth Doing

It's worth being specific about what you're actually fixing, because "Next.js is more popular" isn't a reason to touch a working codebase.

Slow initial page load. A Vite SPA built with the default @vitejs/plugin-react setup ships as one client-rendered bundle. The browser has to download it, parse it, execute it, and only then can your app start firing off requests to load data. Every dependency you add makes this worse, because it's all sitting in the critical path before a single pixel of real content appears.

Waterfalls you can't code-split your way out of. The instinct is to fix bundle size with manual code splitting — lazy-load routes, split vendor chunks. In practice this is easy to get wrong and easy to accidentally make worse, because a naive split just moves the waterfall instead of eliminating it: your outer shell loads, decides which chunk to fetch next, waits for that request, and only then discovers it needs to fetch data too. Next.js's router does this splitting automatically per-route, and — more importantly — gives you the option to fetch data on the server where there's no network round-trip between "decide what to render" and "have the data to render it."

No server rendering option, ever. This is the fundamental difference, not a nice-to-have. A Vite React SPA has no server; index.html ships an empty <div id="root"> and JavaScript fills it in. There's no incremental path from that architecture to server rendering — you'd be bolting on an entirely separate server. Next.js gives you that server for free, and lets you decide, page by page, whether something renders at build time, on each request, or purely in the browser.

If none of this matters for your app — say, it's an internal admin tool behind a VPN where a two-second blank screen is a non-issue — this migration probably isn't worth your time. This guide is for apps where load performance, SEO, or a future move toward server rendering actually matter.

The Strategy: SPA First, Framework Second

The guide's central idea, and the one thing I'd tell you to internalize before touching any code, is this: your first migration target is not "a Next.js app," it's "the same SPA, running inside Next.js's shell."

Concretely, that means:

  • You do not migrate React Router to the App Router in this pass.
  • You do not split your components into Server and Client Components in this pass.
  • You configure Next.js in output: 'export' mode, which produces a static SPA build — conceptually the same shape of output Vite already produces.
  • Your entire existing app renders inside a single Client Component, wrapped by one catch-all route.

This sounds almost too simple to be a real migration, and that's exactly why it works. Every other approach I've seen someone attempt — porting the router and the component tree in the same pass — turns into a multi-week rewrite with a broken app in the middle of it for most of that time. This approach gives you a shippable, working app after a single afternoon, and the App Router migration becomes a separate, later, opt-in project you can do one route at a time.

Step 1: Install Next.js

Nothing surprising here — add next as a dependency alongside whatever you're already using:

npm install next@latest
yarn add next@latest
pnpm add next@latest

You're not removing Vite yet. It stays in your package.json until the very last step, so if something goes wrong halfway through, you can still fall back to your old vite dev command.

Step 2: Create the Next.js Config

Add next.config.mjs (or .js — Next.js accepts either) at your project root:

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export", // produces a static SPA build, same shape as Vite's output
  distDir: "./dist", // match Vite's conventional output folder name
};

export default nextConfig;

output: 'export' is the setting that keeps this migration honest to the "SPA first" plan. It tells Next.js to prerender everything to static HTML/JS/CSS with no Node.js server required at runtime — the same deployment model a Vite build already has. You can drop this later once you start using features that need a server (Server Actions, ISR, dynamic Route Handlers), but for now it keeps the mental model identical to what you're used to.

Step 3: Fix Up tsconfig.json

If you're on TypeScript, Next.js needs a handful of specific compiler options to make its type-checking and dev tooling work. Skip this step entirely if you're on plain JS.

The changes, all in one pass:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "allowJs": true,
    "forceConsistentCasingInFileNames": true,
    "incremental": true,
    "plugins": [{ "name": "next" }]
  },
  "include": ["./src", "./dist/types/**/*.ts", "./next-env.d.ts"],
  "exclude": ["./node_modules"]
}

The two most likely to bite you if skipped are "plugins": [{ "name": "next" }], which enables Next.js-aware type-checking in your editor (route validation, config typing), and esModuleInterop: true, which some Vite configs leave off by default. Also drop your tsconfig.node.json project reference — that file existed to type-check vite.config.ts itself, and you're about to delete that config file anyway.

Step 4: Turn index.html Into a Root Layout

This is the step that trips people up conceptually, because Vite and Next.js solve the same problem — "what wraps every page" — in structurally different ways. Vite's answer is a static index.html with an empty root div. Next.js's answer is a root layout.tsx: a Server Component that returns the actual <html> and <body> tags and renders {children} in between.

Create app/layout.tsx and move your index.html content in:

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="icon" type="image/svg+xml" href="/icon.svg" />
        <title>My App</title>
        <meta name="description" content="My App is a..." />
      </head>
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  );
}

Notice what's already missing compared to a raw index.html: no <meta charset>, no <meta viewport>. Next.js injects both of those automatically, so carrying them over is not just unnecessary, it'll produce a duplicate-tag warning.

Push this further and move your favicon, description, and title into Next.js's typed Metadata API instead of raw <head> tags:

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "My App",
  description: "My App is a...",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <div id="root">{children}</div>
      </body>
    </html>
  );
}

This isn't just tidiness. Once your metadata lives in an exported metadata object instead of hardcoded markup, every page and layout underneath the root can override individual fields (title, description, Open Graph tags) without duplicating the whole <head> — something that was genuinely painful to do correctly with a static index.html.

One more free win here: if you drop favicon.ico, icon.png, or robots.txt directly into the top level of your app directory, Next.js discovers them automatically and wires up the appropriate <head> tags itself — you can delete the manual <link> tags for those entirely.

Step 5: The Catch-All Entry Point

This is the piece that actually makes "keep my existing router" possible, and it's worth understanding rather than just copy-pasting.

Create a folder literally named [[...slug]] inside app:

app/
  layout.tsx
  [[...slug]]/
    page.tsx
    client.tsx

That double-bracket, triple-dot syntax is Next.js's optional catch-all route segment. Read literally: match any path, including the empty path (/), and hand it to this one page.tsx. In other words, you're telling the App Router "don't try to understand my routes yet — just forward every single URL to one file," which is exactly what you want when React Router (or whatever you're using) is still doing all the actual routing work client-side.

The page file is a Server Component by default and stays deliberately boring:

// app/[[...slug]]/page.tsx
import "../../index.css";
import { ClientOnly } from "./client";

export function generateStaticParams() {
  return [{ slug: [""] }];
}

export default function Page() {
  return <ClientOnly />;
}

generateStaticParams returning a single empty-slug entry tells Next.js's static export exactly one HTML file needs to be prerendered — the index — because your client-side router is going to take over navigation from here regardless of what path the browser lands on.

The actual rendering of your existing app happens in a Client Component, loaded with ssr: false so Next.js doesn't even attempt to run it on the server during the build:

// app/[[...slug]]/client.tsx
"use client";

import dynamic from "next/dynamic";

const App = dynamic(() => import("../../App"), { ssr: false });

export function ClientOnly() {
  return <App />;
}

This is the crux of the whole migration strategy: your App.tsx, your React Router routes, your Redux store, all of it, is untouched. It's just being mounted by Next.js instead of by main.tsx. Everything downstream of <App /> behaves exactly as it did under Vite, because as far as React is concerned, nothing changed.

Step 6: Static Image Imports Behave Differently

This one is a quiet source of confusing build errors if you don't know to look for it. Vite's default behavior for import image from './img.png' is to resolve to a plain string — the built asset's URL. Next.js instead resolves the same import to an object, carrying src, width, height, and a blur placeholder if applicable.

If your existing components do this:

import image from "./img.png";

export default function Logo() {
  return <img src={image} />; // breaks under Next.js — image is now an object
}

You need this instead:

import image from "./img.png";

export default function Logo() {
  return <img src={image.src} />; // .src pulls the string back out
}

The guide's advice — and I'd second it — is to keep plain <img> tags during this migration rather than switching straight to Next.js's <Image> component. <Image> gives you real benefits (automatic optimization, layout-shift prevention via inferred dimensions), but it also auto-sets width/height from the source file, which can visually distort images in components that were only styling one dimension with the other left un-autod. Swapping <img> for <Image> while also swapping your entire bundler is two migrations happening at once — do the image component upgrade later, deliberately, one component at a time, once the rest of the app is stable on Next.js.

If you were importing from /public with an absolute path, that needs to become relative too:

// Before (Vite)
import logo from "/logo.png";

// After (Next.js)
import logo from "../public/logo.png";

Step 7: Environment Variables — Watch the Prefix

Both tools use .env files and both expose a subset of variables to client code via a prefix, but the prefix itself is different, and it's an easy one-line find-and-replace:

# Before
VITE_API_URL=https://api.example.com

# After
NEXT_PUBLIC_API_URL=https://api.example.com

Then update every reference from import.meta.env.VITE_API_URL to process.env.NEXT_PUBLIC_API_URL throughout your codebase. Miss one and you won't get an error — you'll get undefined at runtime, which is a much more annoying thing to track down, so I'd grep for VITE_ across the whole repo rather than relying on memory.

Good news if you were relying on Vite's import.meta.env runtime flags: Turbopack (the bundler Next.js uses by default) supports MODE, DEV, PROD, BASE_URL, and SSR with no code changes required. BASE_URL specifically mirrors your Next.js basePath config, trailing slash included, so it lines up with what Vite gave you. import.meta.glob is also supported, with one small syntax change if you were using the (already-deprecated-in-Vite-5) as option:

// Before (Vite)
const modules = import.meta.glob("./dir/*.txt", { as: "raw" });

// After (Turbopack)
const modules = import.meta.glob("./dir/*.txt", { query: "?raw" });

If your Vite app was served from a sub-path, carry that over as basePath in your Next.js config:

// next.config.mjs
const nextConfig = {
  output: "export",
  distDir: "./dist",
  basePath: "/some-base-path",
};

Step 8: Swap Your package.json Scripts

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

Add .next and next-env.d.ts to .gitignore alongside your existing dist entry. Then run it:

npm run dev

Open http://localhost:3000. If your app renders and behaves like it did under Vite, you've successfully migrated the hard part. Everything from here is cleanup and, eventually, incremental adoption.

Step 9: Delete the Vite Scaffolding

Once you've confirmed dev and a production build both work, remove what's no longer used:

  • main.tsx
  • index.html
  • vite-env.d.ts
  • tsconfig.node.json
  • vite.config.ts
  • The Vite dependencies themselves (vite, @vitejs/plugin-react, and friends) from package.json

Resist the urge to also delete React Router or restructure your components in this same commit. Ship the migration on its own, verify it in production, and treat everything past this point as a separate, lower-stakes project.

What People Get Wrong About This Migration

A few things I've seen go sideways, none of which are covered by the mechanical steps above:

Trying to do the App Router migration in the same PR. I mentioned this already but it's worth repeating as a warning, not just a suggestion. The entire value of this approach is that it isolates risk — bundler swap in one change, router adoption in another, component architecture in a third. Collapsing them defeats the purpose and makes a broken build much harder to bisect.

Forgetting that Client Components still prerender. It's tempting to assume 'use client' means "the server ignores this file entirely." It doesn't — Client Components are still rendered to HTML on the server (or at build time, in a static export) before being sent to the browser and hydrated. The ssr: false option on your dynamic() import is what actually opts your legacy <App /> out of that — without it, Next.js will still attempt to render your React-Router-driven app tree on the server, and depending on what that tree touches (browser-only APIs, window, third-party widgets), that can fail loudly during the build.

Leaving stale VITE_ env var references. As mentioned, this fails silently. Grep before you assume you're done.

Assuming output: 'export' is permanent. It's a starting point, not a life sentence. The moment you want a Server Action, a dynamic Route Handler, or on-demand ISR, that config line has to go, and your deployment target needs to support a Node.js runtime (or the Edge Runtime) instead of pure static hosting. Plan for that if you know you'll want those features eventually — it affects where you deploy.

Where To Go From Here

Once the SPA-in-Next.js-clothing is live and stable, the framework's actual value is still sitting unused. The natural next moves, roughly in the order I'd tackle them:

  1. Migrate off React Router to the App Router, one section of your app at a time, to unlock automatic code splitting and streaming.
  2. Move data fetching into Server Components where it makes sense, eliminating client-server waterfalls for at least your initial page loads.
  3. Swap <img> for next/image now that you're not juggling two migrations at once.
  4. Swap custom font loading for next/font, which self-hosts and eliminates the extra round-trip to a font CDN.
  5. Update your ESLint config to include Next.js's rule set, which will catch a handful of App Router foot-guns (invalid <img> usage, missing keys in generated routes, and similar) before they reach production.

None of these are urgent. The entire point of the migration strategy in this guide is that you get to choose the pace.

Key Takeaways

Migration concernWhat changesWhat stays the same
RoutingNothing, initiallyReact Router (or your router of choice) keeps working via the catch-all route
App shellindex.htmlapp/layout.tsxSame <head> content, now typed and mergeable
Entry pointmain.tsxapp/[[...slug]]/page.tsx + client.tsxYour <App /> component tree, unchanged
Static imagesImport now returns an object, not a stringKeep <img>, just append .src
Env varsVITE_*NEXT_PUBLIC_*.env file format itself
Build outputoutput: 'export' produces a static SPASame static-hosting deployment model as Vite
Server rendering, code splitting, streamingAvailable whenever you're readyNot required on day one

The migration described here isn't the destination — it's a deliberately minimal first step that gets you a working, deployable Next.js app without touching your router or your component architecture. Everything that makes Next.js worth the move — server rendering, automatic code splitting, built-in image and font optimization — is still there waiting for you to adopt on your own schedule, one incremental change at a time.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper