# Migrate Vite to Next.js Without Breaking Routing

*Originally published at* [*techpotions.com*](https://techpotions.com/lab/migrate-vite-to-next-js)*.*

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](https://techpotions.com/services/migrations) 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 |
| --- | --- | --- | --- | --- |
| /dashboard | GET | user profile, workspace list | yes | fetches in useEffect |
| /settings/billing | GET | invoices, payment methods | yes | fetches in useEffect |
| /public/:slug | 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:

```tsx
// Before (Vite, react-router-dom)
<Route path="/dashboard" element={<DashboardPage />} />
```

You create `app/dashboard/page.tsx` and render the same component:

```tsx
// 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:

```tsx
// 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](https://techpotions.com/lab/nextjs-vs-vite-guide-month-six) 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:

```tsx
// 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:

```tsx
// 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 to `process.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, or `getServerSideProps`\-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](https://nextjs.org/docs/app/guides/migrating/from-vite) suggests `app/[[...slug]]/page.tsx` as a temporary catch-all. It works like this:

```tsx
// 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.env` reference is resolved, `NEXT_PUBLIC_*` for client-safe values, unprefixed `process.env.` for server-only. No `VITE_` strings remain.
- [ ] No secrets in the client bundle, grep the 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](https://www.inngest.com/blog/migrating-from-vite-to-nextjs), 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.
- **localStorage in server-rendered paths.** Any code path that previously ran on the client, token retrieval, theme preference, onboarding flags, now sometimes runs on the server. `localStorage` is undefined there, and you get a cryptic `ReferenceError` or a hydration mismatch. The fix is usually to move the access to a `useEffect` or 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](https://techpotions.com/services/migrations), 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](https://techpotions.com/lab/nextjs-vs-vite-guide-month-six) 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 to 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.

If you'd rather hand the whole move off, tell us what you're migrating and we'll reply within 24h.
