Migrate Vite to Next.js Without Breaking Routing
A field-tested playbook for moving a working Vite SPA to Next.js: routing, data fetching, env vars, and the cutover checklist we actually use.

Most teams that decide to migrate Vite to Next.js do it because they need server-side rendering, SEO, or server-side data — not because they enjoy rewriting a working app. The trap is treating it like a rewrite instead of a move. You don't need to convert every component to RSC on day one. You need the app running on Next's router with the old behavior intact, then a path to incrementally pull data server-side where it earns its keep.
We've run this exact migration for production SPAs. The playbook below is what we actually do, including the pre-cutover checklist and the three things that break first. If you'd rather have someone else run it, our migration service handles Vite-to-Next moves end to end.
Migrate Vite to Next.js: Route Inventory First, Not Last
Before touching a single file, generate a complete inventory of every route and every fetch, axios, or localStorage call in your Vite app. This is the step people skip because it feels like paperwork. It's the difference between a weekend migration and a two-week debugging session.
Open your router file — whether it's react-router-dom, wouter, or hand-rolled — and dump every route to a table:
Vite route | Method | Data fetched | Auth required? | Notes |
|---|---|---|---|---|
| GET | user profile, workspace list | yes | fetches in |
| GET | invoices, payment methods | yes | fetches in |
| GET | published page content | no | public, needs SEO |
While you're at it, grep for fetch(, axios., localStorage, sessionStorage, and import.meta.env. across the codebase. Every single one becomes a decision point.
The route inventory answers the question everyone asks halfway through: "Wait, did we miss a page?" In the project we ran recently, the grep surfaced a localStorage.setItem buried in a utility that was writing auth tokens on login — and no one remembered it until we found it in the inventory. That's the kind of thing that becomes a mysterious 500 after cutover.
Convert Routes to Next.js App Router Patterns
Next.js App Router maps directories to routes. Your Vite client-side routes have to become files. The official guide suggests creating a [[...slug]] directory and a page.tsx as a catch-all so the app works immediately. That's the right move — get it running, then refactor route by route.
Here's the pattern we follow once the catch-all is in place. Say you have a Vite route like /dashboard that renders a DashboardPage component:
// Before (Vite, react-router-dom)
<Route path="/dashboard" element={<DashboardPage />} />You create app/dashboard/page.tsx and render the same component:
// app/dashboard/page.tsx
import { DashboardPage } from '@/pages/DashboardPage';
export default function Page() {
return <DashboardPage />;
}For dynamic routes, the syntax changes from /public/:slug to app/public/[slug]/page.tsx with params passed as props:
// app/public/[slug]/page.tsx
export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <PublicContent slug={slug} />;
}The key: don't try to convert route components to server components on day one. Keep everything a client component ('use client' at the top) until the app is running. You can incrementally pull data fetching to the server afterward. We wrote about the Next.js vs Vite tradeoff from the perspective of month six — after the honeymoon — and the pattern that sticks is: migrate first, optimize later.
Move Client Fetches to the Server Where They Belong
Once the app is running on Next.js, the highest-leverage work is pulling data fetching out of useEffect and into server components or route handlers. This is the actual reason you migrated. A useEffect fetch fires after the component renders, which means your SEO-sensitive pages ship empty HTML to the crawler and hope the bot waits for JavaScript.
In a Vite SPA, a typical public page component looks like this:
// Vite: data fetched client-side after mount
function PublicPage({ slug }: { slug: string }) {
const [post, setPost] = useState(null);
useEffect(() => {
fetch(`/api/posts/${slug}`)
.then((res) => res.json())
.then(setPost);
}, [slug]);
if (!post) return <Spinner />;
return <article>{post.content}</article>;
}After migration, this becomes an async server component — no useState, no useEffect, no loading spinner for the crawler:
// app/public/[slug]/page.tsx — server component
export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetch(`https://api.internal/posts/${slug}`).then((res) => res.json());
return <article>{post.content}</article>;
}Not every fetch moves. Interactive dashboard widgets that depend on real-time user state often stay client-side. The rule of thumb we use: if the data is the same for every visitor and doesn't depend on browser state, fetch it on the server. If it's personalized or interactive, keep it client-side.
Keep Environment Variables Out of the Client Bundle
This is the migration step that silently breaks production. Vite exposes env vars with import.meta.env.VITE_* and bundles them into the client. Next.js has the same convention — NEXT_PUBLIC_* variables are inlined into the browser bundle — but it introduces server-only variables too, and the boundary is where teams leak secrets.
The migration to run:
- Rename every
import.meta.env.VITE_*reference toprocess.env.NEXT_PUBLIC_*for anything that needs to be in the client bundle. - For anything that should live server-side only — API keys, database URLs, signing secrets — drop the prefix and access it via
process.env.in server components, route handlers, orgetServerSideProps-style logic. - Grep for
VITE_after the rename to make sure nothing slipped through.
A common mistake: a dashboard page has an API URL that was VITE_API_URL in Vite. Someone renames it to NEXT_PUBLIC_API_URL because it's simpler. Now that URL is visible in the client bundle to anyone who opens devtools. If the URL is internal or the key was previously server-proxied, that's a leak. The fix is to move the fetch to the server and keep the variable unprefixed.
Assets, Auth, and the Catch-All Route
These three are the usual suspects for what breaks after the app is "working."
Static assets
Vite serves files from public/ at the root. Next.js does the same — public/favicon.ico maps to /favicon.ico. The catch: imports that reference files with relative paths inside components. In Vite, import logo from './logo.png' works because Vite processes it. In Next.js, you need next/image or a static import into the public/ directory. Run through every <img> and background-image and confirm the source resolves after migration.
Auth
Client-side auth written for a Vite SPA typically stores a token in localStorage and injects it into fetch headers via an interceptor. On Next.js, that pattern still works for client components, but it breaks for server-side data fetching — there's no localStorage on the server. The migration path: keep the token handling in a client component or middleware that sets cookies instead of localStorage, so the fetch on the server can read the cookie. If you're using NextAuth or a library like it, the integration replaces the Vite-specific middleware entirely.
The catch-all route
The official Vite-to-Next migration guide suggests app/[[...slug]]/page.tsx as a temporary catch-all. It works like this:
// app/[[...slug]]/page.tsx
export default function CatchAllPage() {
return <LegacyAppRouter />; // your old Vite router component
}Your entire old SPA mounts inside the catch-all. Every Vite route continues to work at its old path. Then you migrate route by route, creating real app/dashboard/page.tsx files and deleting the corresponding entries from the legacy router. When the last route migrates, you delete the catch-all and the old router entirely. This is the incremental path that keeps the app in production throughout the migration.
The Pre-Cutover Checklist We Actually Run
Before deleting the Vite app, we run through this list. We built it out of the failures we've seen on real projects:
- [ ] Route inventory is complete — every Vite route has a corresponding Next.js path, or is intentionally deprecated.
- [ ] Every
import.meta.envreference is resolved —NEXT_PUBLIC_*for client-safe values, unprefixedprocess.env.for server-only. NoVITE_strings remain. - [ ] No secrets in the client bundle — grep devtools network tab for suspicious env keys on production.
- [ ] Auth works on server fetches — if any data is fetched server-side, tokens are in cookies, not
localStorage. - [ ] Static assets resolve — every image, font, and favicon loads from the new
public/directory. - [ ] Redirects from the old SPA are mirrored — if the Vite app had client-side redirects (auth guards, 404 fallback), they're replicated in Next.js middleware or route configs.
- [ ] SEO pages return server-rendered HTML — view-source on a primary marketing page and confirm content is in the HTML, not just the JS bundle.
The Three Things That Break First
In our experience — and the experiences of teams who've done this migration, including the Inngest team's writeup of their own Vite-to-Next migration — there's a consistent pattern:
- Env vars. The renaming is straightforward, but the leak is invisible. The app works in dev and production because the variable was inlined into the bundle and the value was correct. Three weeks later, someone finds the API key on a public GitHub repo or in the browser devtools.
localStoragein server-rendered paths. Any code path that previously ran on the client — token retrieval, theme preference, onboarding flags — now sometimes runs on the server.localStorageis undefined there, and you get a crypticReferenceErroror a hydration mismatch. The fix is usually to move the access to auseEffector a'use client'component.- Assets with relative imports. Vite-era components often do
import avatar from './avatar.png'and it works because Vite bundles it. In Next.js, that import either fails at build time or resolves to a path that 404s in production. The fix takes ten minutes per asset, but there are often dozens of them.
If any of this sounds like more than your team wants to own, we run Vite-to-Next migrations as a focused engagement — route inventory, env var audit, incremental catch-all cutover, and the checklist signoff. Or if you're weighing whether the migration is even worth it, the six-month retrospective on Next.js vs Vite lays out the actual costs and wins after the dust settles.
FAQ
How long does it take to migrate a Vite app to Next.js?
For a small SPA with 5-10 routes and minimal env vars, a weekend is realistic. For a larger app with auth, dynamic data fetching, and dozens of assets, expect one to two weeks of focused work using the incremental catch-all approach. The route inventory itself takes a few hours, and moving data fetching server-side is where the bulk of the time goes.
Do I have to convert everything to Server Components during migration?
No. The official Next.js migration guide recommends getting the app running first with a catch-all route, then adopting Server Components incrementally. On day one, every component can remain a client component. The server-side data fetching work happens after the app is live on Next.js.
Can I keep using react-router after migrating to Next.js?
Temporarily, yes — the [[...slug]] catch-all approach mounts your existing router inside a single Next.js page. But it's a stopgap. The end state should be Next.js's file-based routing so you can take advantage of server rendering and SEO. Keeping react-router long-term means you're paying the cost of the migration without most of the benefit.
What's the biggest risk when migrating Vite to Next.js?
Shipping a secret to the client bundle. NEXT_PUBLIC_* variables are inlined into the JavaScript that any visitor can inspect. If you rename a VITE_ env var that contained an internal URL or API key to NEXT_PUBLIC_* out of convenience, you've exposed it. The fix is to move that data fetching to the server and keep the variable server-only.