Overview
These two days, I experienced the first vigorous and intense major project refactoring of my life. The whole process was full of pitfalls, my technical understanding was repeatedly challenged, and I even used a bug in reverse to solve a deployment problem. Looking back now, it’s still absurd and unforgettable. I’m recording all the details here for future review (most likely not) and to help fellow developers who might step into the same pitfalls avoid them.
Refactoring Trigger: v0.app Incompatibility
The reason for the refactoring is simple: the boss purchased the team plan of v0.app to improve development efficiency. The original intention was to allow non-technical colleagues to directly preview pages and participate in simple collaborative modifications through v0, reducing the communication cost between front-end and back-end teams. However, when I imported the project repository into v0 according to its guidelines, I immediately stepped into the first fatal pitfall — v0.app only supports the rendering modes of theNext.js framework. It is completely incompatible with our currentVite + React SPA project. After import, it cannot recognize routes or preview pages, which is equivalent to wasting money on the team version.
The boss’s expectation was "import today, use tomorrow". Helplessly, I had to bite the bullet and take on the task: to "violently refactor" the originally fully functional Vite + React SPA project into a Next.js framework project. Before starting, I naively thought this would just be a simple "scaffold replacement + directory structure adjustment". After all, both Vite and Next.js are React-based build tools withReact syntax as their core. At most, I would only need to modify the entry file, adjust dependencies, and finish it in half a day. Looking back now, I was still too young at that time and underestimated the difficulty of front-end framework migration.
Preliminary Preparation: Environment and Directory Migration
Since I had decided to carry out the "violent refactoring", I started with the most basic environment setup and directory migration to lay a solid foundation first. First, I uninstalled all Vite-related dependencies (@vitejs/plugin-react, vite, vite-plugin-xxx, etc.), and reinitialized the Next.js project using the latest version 14.0.3 (becausev0 has the best compatibility with Next.js 14+). The initialization command was npx create-next-app@latest . --typescript --eslint --tailwind --app. I chose the App Router mode (v0 only supports App Router, not Pages Router) and disabled the src directory (to keep the structure consistent with the original Vite project and reduce the cost of subsequent file migration).
Next was the refactoring of the directory structure, which is also the most basic but tedious step. The directory structure of the originalVite project was: public/ (static resources), src/ (core code), src/components/ (common components), src/pages/ (page components), src/router/ (React Router configuration), src/api/ (mock APIs), src/utils/ (utility functions), src/styles/ (global styles). The entry file was src/main.tsx, which was mounted to the #root node via ReactDOM.createRoot.
However, the directory specifications of Next.js App Router are completely different. Its core is that the app/ directory serves as the root directory for routes. Each folder corresponds to a route path, page.tsx is the entry component of the route, layout.tsx is the layout component of the route, loading.tsx is the loading state component, and error.tsx is the error handling component. Therefore, I needed to migrate all page components under src/pages/ of the original Vite project to the app/ directory one by one, create corresponding folders according to the route paths, and rename them to page.tsx. The original src/components/ directory was directly migrated to the project root directory (at the same level as app/) without modifying the internal code of the components (for the time being). The original global styles in src/styles/ needed to be imported into app/layout.tsx to replace the default global styles of Next.js. The original src/router/ directory (React Router configuration) was directly deleted because Next.js adopts a file-system-based routing and does not require manual route configuration. The mock APIs in the original src/api/ directory needed to be refactored into Next.js API Routes (create corresponding API files under theapp/api/ path).
Although the environment and directory migration seemed simple, I actually stepped into two small pitfalls. First, the @ alias path used in the originalVite project needed to be manually configured in Next.js. I added a paths configuration in next.config.js to specify that @ corresponds to src/ (even though the src directory was disabled, some subsequent utility functions still needed to use the @ alias). Second, there was a configuration conflict with Tailwind CSS. In the tailwind.config.js of the original Vite project, the content was configured as src/**/*.{js,jsx,ts,tsx}. After migrating to Next.js, it needed to be modified to app/**/*.{js,jsx,ts,tsx} and components/**/*.{js,jsx,ts,tsx}; otherwise, the styles would not take effect normally. It took me more than half an hour to identify and fix this problem.
Core Difficulty: Rendering Mode and Component Splitting
After finishing the basic environment and directory setup, the real difficulty began — switching the rendering mode and splitting components. This step directly challenged my technical understanding repeatedly, forcing me to reshape my perception of front-end rendering. It was also the most frustrating and time-consuming step in the entire refactoring process, completely breaking the boundary of my understanding of front-end SPA and SSR frameworks. The original Vite project was a pure SPA application using client-side rendering (CSR), with a very straightforward logical flow: one path corresponds to one component, and all logic is contained on the client side.
But after switching to the Next.js App Router mode, everything changed — Next.js uses Server Components (SC) by default. No additional instructions are needed; components are rendered on the server side by default. They cannot use React Hooks (such as useState and useEffect), cannot access client-side global objects like window and document, and cannot import client-side related dependencies (such as axios and react-router-dom). Only components with the "use client" directive added are Client Components (CC), which can use Hooks, access client-side global objects, and handle interactive logic.
This means that I needed to split and classify all components in the original Vite project one by one, determining which components are suitable as Server Components and which must be Client Components. For example, pure display components (without interaction, state, or dependence on client-side objects), such as Button, Card, and Input common components, can be retained as Server Components without adding "use client", which can optimize first-screen rendering speed and SEO. However, components containing interactive logic (such as form submission, pop-ups, drop-down menus, and components using useState to manage state) must have the "use client" directive added to be converted into Client Components; otherwise, an error "Error: Cannot use useState in Server Components" will occur.
More complex was the refactoring of data requests. In the original Vite project, all data requests were completed on the client side using axios to send requests, triggering requests in useEffect, and updating component state after obtaining data. However, in Next.js, Server Components cannot use axios (a client-side dependency) or useEffect. Therefore, data requests need to adopt a server-side preloading method — using async/await to directly request data in Server Components (without relying on axios, the fetch API can be used, which is encapsulated by Next.js to support caching). After the data preloading is completed, the data is passed to sub-components (whether they are Server Components or Client Components).
I stepped into a fatal pitfall here: in the original project, there was a user activity management and activity calendar component that relied on window.localStorage to obtain the user token and then send a request to get user information. After migrating to Next.js, I initially did not add "use client" to this component, resulting in an error "window is not defined" during server-side rendering. It took me more than an hour to realize that this component depends on client-side objects and must be converted into a Client Component. In addition, some page components contained both display logic and interactive logic. I had to split them into two components: the parent component is a Server Component responsible for preloading data and rendering display content; the child component is a Client Component responsible for handling interactive logic and receiving preloaded data from the parent component through props. This not only optimizes first-screen rendering but also ensures normal interaction.
During this process, I was forced to re-learn the rendering mechanism of Next.js, understand the differences between SSR (Server-Side Rendering), SSG (Static Site Generation), and ISR (Incremental Static Regeneration), and also clarify the principle of dividing the boundary between Server Components and Client Components — "do as much as possible on the server side". This can reduce the rendering pressure on the client side and optimize first-screen loading speed and SEO. To be honest, this part of the content is too new to me. Currently, I only have a basic grasp of the splitting and usage methods and have initially completed the refactoring. If the workload permits in the future (mainly depending on my desire to slack off), I plan to further learn and optimize Next.js rendering, such as ISR caching strategy and the caching mechanism of Server Components, to further improve project performance.
Frustrating Challenge: Route, API and Vercel Limits
After barely solving the problems of component splitting and rendering mode switching, I immediately turned to refactoring routes and APIs. This step can be described as "struggling to the end with Vercel deployment limits", involving two core difficulties: route compatibility and deployment limits, which almost made me give up the refactoring.
First was route refactoring. The original Vite project used React Router v6, adopting a manually configured routing method that supports dynamic routes (such as/:id, /:userId/posts, etc.), nested routes, and route guards. However, Next.js App Router adopts file-system-based routing, where the route structure is determined by the folder structure. The configuration method of dynamic routes is completely different from that of React Router. In Next.js, dynamic routes require creating folders with square brackets, such as [id] and [userId], corresponding to /:id and /:userId in React Router. For fuzzy-matching dynamic routes (such as/* and /:path* in React Router),Next.js requires using the [...path] catch-all mechanism and creating a [...path] folder, which can catch all nested route paths and achieve compatibility with fuzzy-matching routes in React Router.
To be compatible with all routes in the original project, I had to sort out all the original React Router configurations one by one and convert each dynamic route and nested route into the file-system-based route structure of Next.js. For example, the original route configuration<Route path="/user/:id" element={<UserPage />} /> required creating a user folder under the app/ directory, then creating an [id] folder under theuser folder, and creating a page.tsx under the [id] folder to migrate the code of the original UserPage component to this page.tsx. The original route configuration <Route path="/home/*" element={<HomeLayout />} /> required creating a home folder under the app/ directory, then creating a [...path] folder under the home folder, and creating a page.tsx under the [...path] folder to catch all subpaths under /home.
The most troublesome part of route refactoring was obtaining route parameters. In the original React Router, dynamic route parameters are obtained through theuseParams() hook. However, in Next.js Server Components, useParams() cannot be used; instead, dynamic route parameters need to be obtained through the parameters (params) of the default export function ofpage.tsx. In Client Components, although useParams() can be used, the "use client" directive must be added first, and the return value type of useParams() is slightly different from that in React Router. Manual adjustment of type definitions is required to avoid TS errors (the original project usesTypeScript with strict type checking).
After solving the route compatibility problem, the next step was the pitfall of API refactoring and Vercel deployment limits. The API part of the original Vite project consisted of local mock APIs usingMock.js, placed in the src/api/ directory, which were only used in the development environment. In the production environment, they would be switched to real back-end APIs. However, after migrating to Next.js, to allow v0.app to preview pages normally (v0 needs to access the project’s API interfaces to obtain data), I needed to refactor the original mock APIs into Next.js API Routes. Next.js API Routes need to be created under the app/api/ directory, where each API interface corresponds to a file. The file exports an async function by default, which receives request and response parameters, processes the request, and returns a response. Essentially, they are Node.js-based Serverless Functions.
There were not many interfaces in the original project. I created corresponding API files under the app/api/ directory according to the specifications ofNext.js API Routes, with each file handling one API request. However, after completing the refactoring of all API interfaces and deploying them to Vercel (the official deployment platform ofNext.js, which is directly associated with v0.app by default), I stepped into another fatal limit — Vercel’s Hobby plan (free version) has a strict limit on the number of Serverless Functions, allowing a maximum of 12Serverless Functions. But I clearly knew that the number of API interfaces was far less than 12, so why did it trigger the limit instead?
To solve this problem, I stayed up late last night trying various methods to reduce the number of Serverless Functions repeatedly: merging multiple functionally similar API interfaces into one file and distinguishing different interface logics through request parameters (such as type); deleting some unnecessary mock APIs and retaining only the core interfaces needed for v0 preview; trying to merge interfaces using Next.js Route Handlers, routing all API requests to a single file, and then distinguishing different interfaces through request paths. According to my calculations and sorting, the number of Serverless Functions was theoretically even less than before, let alone reaching 12. However, when deploying to Vercel, the system still prompted that the 12-Serverless-Function limit had been exceeded, and multiple deployment attempts failed. This phenomenon aroused my doubts and inspired me to delve into a key question: when deploying a Next.js project toVercel, which parts are judged as Serverless Functions?
After checking the official Vercel documentation, I realized that my initial understanding had a clear deviation: the Serverless Functions judged by Vercel include not only the API Routes under the app/api/ directory but also allpage.tsx and layout.tsx files using dynamic rendering (such asSSR and ISR), as well as middleware files that need to be executed on the server side. In other words, any component containing logic such asasync/await preloading data or relying on the server-side environment will be packaged into an independent Serverless Function, rather than my original assumption that "only API Routes count". This also explains why I still triggered the limit even though I had reduced the number of API interfaces.
What impressed me more was that the same project never triggered this limit when deployed to Vercel using Vite. Comparing the core differences between the two frameworks, I realized: Vite is a pure client-side build tool that outputs static HTML, CSS, and JS files after packaging. When deployed to Vercel, it is recognized as a static site, requiring no Serverless Functions to be started, so it is naturally not subject to the quantity limit. In contrast, Next.js is a full-stack framework whose core advantage lies in server-side rendering and Server Components. During deployment, all server-side related logic (API Routes, dynamically rendered components, etc.) is automatically packaged into Serverless Functions. Even if I did not actively write too many APIs, dynamically rendered pages would occupy Function quotas. This is one of the most essential differences betweenNext.js and Vite — one is "client-first, extensible to server-side", and the other is "deep integration of server-side and client-side".
Unexpected Solution: Bypass Limits with Bugs
Unfortunately, after tossing around for most of the night, I still couldn’t solve the problem of Vercel’sServerless Function quantity limit. I even suspected that v0.app only supports Next.js to collude with its parent companyVercel, forcing users to deploy on Vercel and then compelling them to upgrade to a paid plan with no limit on the number ofServerless Functions. The more I thought about it, the more frustrated I became, and I almost gave up the refactoring entirely.
Just as I was frustrated and ready to slack off, an unexpected discovery helped me find a way out and smoothly entered the subsequent deployment and debugging phase. I suddenly realized that when I was debugging the API interface earlier, I asked theAI Agent to help modify an interface path, resulting in a mismatch between the interface path and the interface path of the back-end Express service. After deploying this version to Vercel, it could not send requests normally and could not log in (after all, our project adopts a front-end and back-end separation architecture, and the back-endExpress service is independently deployed on our own server, unrelated toVercel).
Originally, this was a bug that needed to be fixed, but I suddenly realized that this bug could instead solve the problem of Vercel’s Serverless Function quantity limit. Because the interface paths did not match, this version onVercel would not actually send API requests, and thus would not trigger the call of Serverless Functions. It was equivalent to the version on Vercel being just a "static preview version" — only used for v0.app preview and non-technical colleague collaboration, without the need to call real APIs. Naturally, it did not need to rely on Next.js API Routes, and the number of Serverless Functions would not exceed the limit.
So, I happily decided to keep this bug and not fix the interface path mismatch. The specific deployment plan was adjusted as follows: 1. Deploy a "preview version" on Vercel: retain this version with mismatched interface paths, which is only used for v0.app preview and non-technical colleague collaboration. It does not need to process API requests, avoiding Vercel’sServerless Function quantity limit. The deployment process does not need to be modified; it is directly associated with the repository for automatic deployment. 2. Deploy an "official version" for the production environment: I manually modified the interface paths to match those of the back-endExpress service, then manually deployed it to our own server to ensure the integrity and security of the service, while avoiding reliance on Vercel’s 12-Serverless-Function limit.
This plan not only met the boss’s requirements (v0.app preview and non-technical colleague collaboration) but also solved the Vercel deployment limit problem. I didn’t have to continue tossing around to reduce the number of Serverless Functions. It can be described as a model of "solving problems by slacking off". I have to say, sometimes bugs can become a "bridge" to solve problems.
Follow-Up Adjustment: Front-End and Back-End Separation
But a new problem soon arose: initially, I only distinguished the configurations between the production environment and the Vercel preview environment during deployment, and did not make any modifications to the repository itself. The front-end and back-end codes still coexisted in the same repository. This caused v0.app to still prompt "deployment not supported" after detecting the presence ofExpress back-end code in the repository, making it impossible to preview the front-end pages normally. Helplessly, I had to adjust the plan first: deploy the complete front-end and back-end services on our own server, then delete allExpress back-end codes in the repository, and make both theVercel preview version and the production front-end version point to the back-end service on the server. However, this temporary plan obviously had serious security risks — directly exposing the Express back-end to the public network without any reverse proxy or protection measures, making it vulnerable to malicious requests and attacks. Therefore, later I also set about thoroughly separating the front-end and back-end, splitting the front-end code and back-end Express code into two independent repositories, which are deployed and maintained separately. The specific splitting details and configurations will not be elaborated for the time being.
Technical Collation: Vite, Next.js and Express Differences
Speaking of which, it’s also a good time to sort out the connections and differences between Express.js and Vite, Next.js we used this time. After all, I dealt with these three technologies throughout the refactoring. I was just immersed in stepping into pitfalls before, and now it’s a good opportunity to clarify the logic. First, the connections: essentially, all three are commonly used tools in front-end/full-stack development and can work together. As a Node.js back-end framework, Express is responsible for providing API interfaces, handling database interactions, and business logic. Vite or Next.js, as front-end frameworks, are responsible for page rendering and client-side interaction, and obtain data by requesting the interfaces provided by Express to form a complete front-end and back-end linkage. Our project initially adopted the"Vite + Express" front-end and back-end separation architecture, and was later refactored to "Next.js + Express". The core linkage logic remained unchanged.
However, the differences between the three are very obvious, with completely different core positioning: Vite is a pure front-end build tool focusing on client-side rendering (CSR). Its core advantages are fast build speed and efficient hot updates. It does not have any server-side capabilities and cannot directly provide API interfaces. It must rely on external back-end frameworks (such as Express) to realize complete business functions. Next.js is a React full-stack framework that not only includes front-end rendering capabilities (supporting multiple rendering modes such as CSR, SSR, and SSG) but also has built-in server-side capabilities (API Routes, Server Components). Even without relying on Express, it can implement simple back-end logic through its own API Routes. However, its core positioning is still front-end, and it is more suitable for handling page rendering and client-side interaction-related work. Express is a pure back-end framework running on Node.js, focusing on handling HTTP requests, route management, middleware configuration, and business logic. It does not have any front-end rendering capabilities and can only provide data support for front-end frameworks (Vite, Next.js) by providing API interfaces.
To put it simply, Vite is a "pure front-end build tool" responsible only for front-end packaging and rendering; Next.js is a "front-end-led full-stack framework" that balances front-end rendering and simple server-side capabilities; Express is a "pure back-end framework" responsible only for back-end logic and API provision. Previously, the three worked together in the project, each performing its own duties. However, precisely because there was no clear front-end and back-end separation architecture design at the initial stage of the project, a series of problems occurred during this refactoring, such as back-end code in the repository interfering with v0 preview and the back-end being exposed to the public network.
Refactoring Summary and Complaints
Although the two-day refactoring process was frustrating and full of pitfalls, it also forced me to grow a lot and gain a deeper understanding of modern front-end full-stack development. In the past, when working on Vite + React SPA, I only focused on client-side interaction logic and page styles, and never thought about technical selection from a more macro perspective such as deployment cost, rendering performance, and architecture compatibility. I always thought "as long as it works". But this refactoring made me fully realize that technical selection is not "based on preference", but must be comprehensively considered in combination with project requirements, team collaboration, deployment environment and other factors.
In addition, this refactoring also made me realize how terrible a "project without architecture" is. I did not participate in this project from the beginning. There was no clear architecture design at the initial stage of the project, and the technical selection was very hasty — the database was chosen randomly, the back-end framework was built casually, there were no unified specifications for the front-end, and various codes were piled up, resulting in a strong "spaghetti code" feeling of the project. During this refactoring, various legendary engineering disasters hit me one after another: type hell (the TypeScript type definitions of the original project were not standardized, many components had no type constraints, and a lot of TS errors occurred after migration),dependency hell (the dependency versions of the original project were chaotic, some dependencies were incompatible with Next.js, requiring one-by-one upgrades or replacements), environment hell (the configurations of the development environment, preview environment, and production environment were not unified, and problems such as missing environment variables and failed dependency installation frequently occurred during deployment). Each problem required a lot of time to investigate and solve.
Finally, I want to complain from the bottom of my heart: I hope that after I officially start working, I will no longer take such a "migrant worker developer position" — no architecture design, no specification constraints, having to clean up all the messes myself, and constantly switching between "firefighting" and "refactoring" every day. This refactoring has made me less willing to learn. I plan to slack off for a few days later. As for in-depth learning of Next.js, it will depend on the subsequent workload and my mood to slack off.