Skip to content
techpotions
nextjs · Vite · migrations · reactOctober 27, 202610 min read

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.

Cover illustration for “Migrate Vite to Next.js Without Breaking Routing”

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

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

  1. Rename every import.meta.env.VITE_* reference to process.env.NEXT_PUBLIC_* for anything that needs to be in the client bundle.
  2. 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.
  3. 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:

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 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:

  1. 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.
  2. 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.
  3. 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.

Written by
techpotions
All entries
How to Convert HTML to Next.js Without Losing SEO
The weekly

One email a week, from the workshop.

What we published, what we shipped, and the free packs as they land. No drip sequence, no webinar, unsubscribe in one click.