engineering
Why we moved our React app from Next.js to Vite and TanStack Router
Contents: Where Next.js stopped paying for itself
Next.js carried our app from its first prototype to a product that people keep open for hours: a video studio with a canvas, a timeline, a chat with our Director and a stream of live updates over a websocket, all behind a sign in. It let a small team ship pages, data fetching and routing quickly, and we would recommend it again to a team starting out. As the app grew, however, the classes of errors that reached production changed character: they became intermittent, tied to deploys, and hard to trace back to our own code, and the time we spent working around the framework began to exceed the time it saved us. This October we moved the app to a Vite single page app with TanStack Router, served as static files from a CDN behind an edge worker, in six pull requests over two days. This piece covers why we made that call, how the move went, and what we measured before and after.
Where Next.js stopped paying for itself
Next.js is at its best when pages render on a server: marketing pages that must index well, content that changes per request, and teams that deploy on a platform built around the framework, which smooths over many of the edges described below. Although that combination carries obvious appeal on paper, our app used little of it as it matured. Every page either sits behind a sign in or is the sign in screen itself, so there is nothing for a crawler to index; the editor fetches its data in the browser and keeps it fresh over a socket, so server rendering produced a shell that hydrated into the real interface a moment later; and we self-host our containers, so the platform features that absorb version skew and server action routing did not apply to us.
None of that made Next.js a poor choice when we started, because the same conventions that later cost us time were what let us build quickly at the beginning. The cost arrived with scale: more users with long sessions, more deploys a day, and a codebase large enough that the framework’s implicit behaviors became a source of bugs. The sections below go through those bugs in the order they hurt.
Version skew: “Failed to find Server Action” after every deploy
The problem that finally forced the decision was version skew. Next.js addresses each server action by an id generated at build time, and those ids change from one build to the next. A browser tab that loaded the app before a deploy keeps calling actions by the old ids, and the new server rejects every call:
UnrecognizedActionError: Server Action "7f3a…" was not found on the server.Read more: https://nextjs.org/docs/messages/failed-to-find-server-actionIn a product where people keep the editor open all day, that is the normal case after a deploy, and the editor polled and refetched through server actions, so a single stale tab produced hundreds of errors. Over two weeks this one error made up 89% of all frontend exceptions we recorded (2,177 of 2,433), and one user hit it 924 times across 39 sessions. Each of those was a save, a refresh or a status check that silently failed until the user reloaded the page.
The remedies Next.js documents centre on its own hosting platform, whose skew protection keeps old deployments reachable for a while, and on a persistent encryption key for action payloads. On our self-hosted containers we settled on a reload instead: detect the error, reload the page once (guarded so it cannot loop), and wait while a text field holds an unsent message. That fix worked, although it treated the symptom, and it made clear that the underlying issue was an RPC layer whose addresses change on every build.
Errors that were hard to trace in production
Version skew was the loudest problem, although the one that cost the most engineering time was quieter: errors whose cause the framework hid from us. In production builds, Next.js replaces the message of an error thrown in a Server Component with a generic one and a digest hash, so the error boundary and our error tracking received the same generic text for unrelated failures, with only the digest to connect each one to the server’s own log.
The framework’s own control flow made our error handling harder as well. redirect() and notFound() work by throwing special errors that carry a digest, so every generic error handler in the app needed a guard to rethrow them before showing a toast, and a redirect thrown inside an HTTP client’s interceptor could be rewrapped by the promise chain, lose its digest, and surface as an ordinary error. Errors returned from server actions arrived as objects shaped like { name, message, digest }, and a call site that passed one straight to a toast crashed the page with “Objects are not valid as a React child”. Each of these had a workaround, and each workaround was another piece of framework-specific code to remember:
/** Next.js redirect and not-found signals must never become toasts. */export function isFrameworkError(value: unknown): boolean { const digest = (value as { digest?: unknown })?.digest const message = (value as { message?: unknown })?.message return (typeof digest === 'string' && (digest.startsWith('NEXT_REDIRECT') || digest === 'NEXT_NOT_FOUND')) || message === 'NEXT_REDIRECT' || message === 'NEXT_NOT_FOUND'}Server actions as an RPC layer
Server actions read like plain function calls, and that is their appeal: a component imports a function marked 'use server' and awaits it. In practice they behaved like a remote procedure call with rules of its own. Actions from one page run one at a time and cannot be cancelled, so a slow write held up every read queued behind it, and an editor that refreshed several panels at once waited on all of them in sequence. Errors cross the boundary through the framework’s serialization, which kept the message and dropped the rest, so a billing error lost the fields the interface needed to show the right prompt. Cookies set inside an action travel back on the action’s own response, and when the workspace switch was routed through a server action, the action’s outer request consumed the Set-Cookie header before it reached the browser, so the session kept the old workspace and the switch looked like a button that did nothing.
Hydration mismatches in a signed-in app
Server rendering a signed-in interface means rendering it twice, once on the server and once in the browser, and React requires both to agree. Any state that only the browser knows breaks that agreement: our layout settings lived in cookies that the server-rendered tree read during render, the server rendered its default, the browser rendered the user’s choice, and React reported a hydration mismatch and discarded the server’s work. The fix each time was to render the default first and read the real value in an effect after hydration, which means the server rendered something the user was never going to see. In an app where every page waits for the session and its data anyway, server rendering added a class of bugs while saving no time on screen.
Memory, build time and resource use
The development server was the most visible cost day to day. Next.js compiles each route the first time it is visited, and in a large App Router project that compilation lives in memory: our dev server held about 6.5 GB steady, and with Node’s default heap it ran out of memory and restarted every few hours, more than 23 times in one week, until we gave it a 6 GB V8 heap. Every development environment had to reserve 4 GiB of memory for it. The Vite dev server for the same app stays under 750 MiB through a full run of our browser test suite, and each environment now reserves 1 GiB.
Production builds told the same story. On the same CI runner, the last Next.js production build reported Compiled successfully in 3.6min and its full build task took 5 minutes 26 seconds, while the first Vite build of the same app reported built in 2.66s and its task took 11.2 seconds. In production the app no longer needs a server process at all, because it ships as files on a CDN.
Next.js also needed a 6 GB heap to stop restarting.
Replaced by a typed client for the API.
Code was already split by route before the move.
The JavaScript a browser downloads on first load barely changed: Next.js reported 396 kB for every route, and the Vite build’s entry plus the modules it preloads come to 382 kB gzipped. Splitting by route was already in place before the move, so the gains were in build time, memory, deploy speed and reliability rather than bundle size, and we report that plainly because a smaller bundle is the result most often promised and least often delivered by migrations like this one.
Framework glue we no longer carry
Several pieces of the old setup existed only to adapt the framework to our needs, and each one was a place for bugs to hide:
- A custom server. To run Next.js next to a websocket server, the app started from its own
server.ts, which also connected to the message bus. When we removed it we found that browsers had always connected to the API server’s websocket, so the frontend’s copy had never received traffic. - Middleware for route guards. Sign in redirects, admin-only pages and billing return pages ran in middleware on every request, in a separate runtime with its own rules about which APIs it may call. Route guards in the client-side layouts replaced it, and the API server already enforced the same rules for data.
- Metadata for crawlers. Recent versions of Next.js stream page metadata after the first bytes of HTML for clients that run JavaScript, so Googlebot received our Open Graph tags in the body instead of the head. The static
index.htmlnow carries them in the head for every client.
What we moved to
The new app is a Vite single page app. TanStack Router builds its route tree in code from one route table that lists every path with its page, layout, guard, loading state and error boundary, which replaced the file name conventions of the App Router with something that can be read, tested and checked in one place. use-intl replaced next-intl with the same API, TanStack Query owns every read and write, and one small navigation module wraps the router so feature code never imports it directly. The build goes to a CDN as static files, and an edge worker in front of the domain decides which files answer which path.
Every app address answers with the same index.html, served with Cache-Control: no-cache so a deploy reaches the next page load, and every hashed asset under /_app/assets/ is served as immutable for a year. A missing file answers 404 with the app’s own not-found page, which keeps a stale chunk from loading HTML as JavaScript.
How we migrated in six pull requests and two days
We never stopped shipping. The move took six pull requests merged over two days, each deployed on its own while the app still ran on Next.js, so that the final swap changed the framework and little else:
The first two steps were the largest by count and the simplest in substance. Before the move the app had 178 server actions in 27 files: the first pull request moved the 57 reads to the browser, and the second moved the remaining 121 mutations, added an Origin check on the API for every state-changing request to keep the cross-site protection server actions used to provide, and turned on a lint rule that refuses any new 'use server' file. That went quickly because our server actions had never held business logic. The API server already published an OpenAPI description, the frontend called it through a generated client, and each server action was a thin wrapper around one generated call, so moving it meant calling the same generated method from the browser through TanStack Query:
// Before: a server action that wraps one generated API call.'use server'export async function renameProject(id: string, name: string) { return serverApi.projects.update({ id, body: { name } })}
// After: the same generated call from the browser, with cancellation and cache invalidation.export function useRenameProject() { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => api.projects.update({ id, body: { name } }), onSuccess: (_, { id }) => queryClient.invalidateQueries({ queryKey: projectKeys.detail(id) }), })}The fourth step did the most to make the last one small. Feature code may import navigation only from one module, and ESLint enforces it, so swapping routers meant rewriting that one file:
// eslint.config.mjs (simplified)'no-restricted-imports': ['error', { paths: [ { name: 'next/navigation', message: 'Use @/lib/navigation.' }, { name: 'next/link', message: 'Use @/lib/navigation.' }, ],}],The route table in the fifth step was the other key piece. Next.js decides a lot from file names alone (404 pages, error boundaries, loading states, layouts, metadata and icons), and each of those behaviors became an explicit entry that a unit test compares against the actual pages, so nothing could silently disappear when the file conventions stopped meaning anything. Across the six pull requests the frontend’s application code lost 12,466 lines and gained 10,748, a net reduction of 1,718 lines, most of it the server action layer and the framework glue described above.
What the cutover caught
The final swap was small in code and still surfaced five bugs, each of which a team making the same move is likely to meet.
Tailwind never loaded. Next.js accepts PostCSS plugins as an array of names. Vite’s PostCSS loader rejects that form with Invalid PostCSS Plugin found at: plugins[0], and in development the error only appeared in the server log while the page rendered unstyled.
// Accepted by Next.js, rejected by Vite.export default { plugins: ['@tailwindcss/postcss'] }
// Accepted by both.export default { plugins: { '@tailwindcss/postcss': {} } }A stylesheet vanished. Our shared CSS imported a library stylesheet after a few @source directives. Next.js inlined it regardless; Vite’s PostCSS import step follows the CSS rule that every @import must come first, warned, and dropped it, so the canvas library’s base styles were missing from the build. Moving every @import to the top fixed it.
Route guards judged the wrong address. TanStack Router updates location as soon as a navigation starts, while the old page is still on screen. Our hooks read location, so during a redirect from /app?open=billing the old page’s guard briefly saw /sign-in, decided the page was public, rendered its children, and a child stripped the query string before the redirect finished. Next.js’s hooks return the committed address, so the fix was to read the location of what is actually rendered:
function useShownLocation<T>(select: (location: ParsedLocation) => T): T { return useRouterState({ select: (state) => select(state.resolvedLocation ?? state.location), }) as T}Stale tab recovery stopped working. Our reload-on-skew logic matched webpack’s ChunkLoadError by name. Under Vite a missing lazy module fails with the browser’s own error, worded differently in each engine (Failed to fetch dynamically imported module in Chromium, error loading dynamically imported module in Firefox, Importing a module script failed in Safari), and Vite reports failed preloads through a vite:preloadError event. The detection now matches those messages and listens for that event. The test that should have caught it threw a hand-made ChunkLoadError, so it now imports a module that does not exist on the server, which produces the error the browser actually throws.
An edge route matched too much. Edge route patterns match by plain prefix, so the route that sent /auth* to the API server also claimed /auth-error, one of the app’s own pages. Before the cutover both went to the same origin and nothing broke; afterwards the error page would have bypassed the app. The fix was a trailing slash in the pattern, /auth/*.
Two habits found most of these. We tested the production build behind the real edge worker code before merging, which caught the stale tab detection and the route that matched too much, and we probed every routed prefix on the live domain from outside before changing any route, which turned up the /auth* overlap in a table of a few dozen lines.
Keeping old chunks for open tabs
A static deploy replaces every file, so version skew does not disappear with Next.js: a tab opened before a deploy still asks for chunks from its own build when it navigates to a page it has not loaded yet. Before each deploy, a short script reads the list of hashed assets the live deployment carries, copies the ones built in the last 14 days into the new build, and writes the updated list, so a tab up to two weeks old still finds its files. The reload-once logic stays as the backstop for anything older.
The cutover itself caused no downtime, because the edge routing changed only after the new build was live and the old server stayed up for one release as the rollback.
TL;DR on moving from Next.js to Vite and TanStack Router
Why did we move from Next.js to Vite? Our app grew into a signed-in editor that fetches its data in the browser, so server rendering added work without adding value, while version skew, server action behavior, hydration mismatches, hard-to-trace production errors and dev server memory kept costing us time. A Vite single page app on static hosting removed those classes of bugs.
What does “Failed to find Server Action” mean in Next.js? It means a browser tab is calling a server action by an id from an older build. Action ids change between builds, so after a deploy every action call from a tab loaded before it fails until the page reloads.
How do we handle “Failed to fetch dynamically imported module” after a Vite deploy? We keep the hashed assets of the last 14 days of deploys in each new deploy, so open tabs still find their chunks, and we reload the page once when a lazy module or a preload still fails.
How much faster is a Vite build than a Next.js build? For our app on the same CI runner, the Vite production build was 81 times faster than the Next.js compile (2.66 seconds against 3.6 minutes), and the full build task was 29 times faster.
How long did the migration from Next.js take? Six pull requests over two days, each shipped on its own, with no downtime at the cutover.
Did the JavaScript bundle get smaller after leaving Next.js? Only by 4%. First-load JavaScript went from 396 kB to 382 kB gzipped, because we already split code by route; the gains were in build time, memory and reliability.
What we would tell a team starting out
Next.js remains a good place to start: it gets a team from an empty repository to a working product faster than assembling the pieces by hand, and many apps never reach the point where its trade-offs bite. The advice we would give from our experience is to keep the framework at arm’s length from the start. Describe the API in a contract such as OpenAPI, generate a client from it, and treat server actions, route handlers and middleware as thin adapters over that client and over plain modules, rather than as the place where data access and business rules live. Our server actions were already thin wrappers around generated calls, which is the only reason 178 of them could move in two pull requests. Confine framework imports to a handful of modules, write routes, guards and error handling down explicitly, and a later move, to another framework or to none, becomes a sequence of small changes. Our next step applies the same thinking to our documentation, which runs on a framework server today and needs nothing more than static files.