We moved away from Next.js 11 months ago and haven't looked back! Why did we do it? What challenges did we face along the way? Read on to find out.
A few years ago, we started developing our web app using Next.js, a frontend framework built on top of React and Node.js. Even though Next.js is primarily designed for applications that can take advantage of hybrid rendering such as e-commerce websites, we still decided to use Next.js over the alternatives at the time (create-react-app, custom webpack config, or GatsbyJS).
The reasons for this were the following:
- Next.js allowed us to "outsource" the webpack config for our project, so we wouldn't have to worry about choosing the best webpack loaders or doing maintenance work such as migrating from Uglifyjs to Terser. With create-react-app, we could also outsource the webpack.config.js, but any change to it would require us to eject the configuration and maintain it ourselves. With NextJS, we could make small tweaks to their default webpack config.
- Next.js ships with routing built-in, so we didn't have to worry about picking a client-side routing library for our application.
- Next.js maintains a set of nice things (Image Optimization, Font Optimization, source map configuration, smart route prefetching, bundling optimizations and polyfill configuration) that we thought could be useful at some point.
With time, however, as we started requiring more customization over our frontend infrastructure, Next.js started making less and less sense for us. The main reason for this is that our application is different from the types of applications Next.js is designed for, and this often got in the way of our customizations. Additionally, we experienced severe performance issues with the local development web server.
So, 11 months ago we moved away from Next.js and we haven't looked back. In this blog post, we'll explore the context for this and all the challenges we faced.
Some context: What is the SingleStore Customer Portal?
The app we built using Next.js is our Customer Portal, and it is used for the Singlestore Helios's UI, which is where you can create, access, resize, destroy, and monitor SingleStore clusters in the cloud. This web application features some simple administrative pages, but also some interactive pages including:
- Visual Explain — a UI for analyzing query plans in order to understand what might be making a query slow.
- SQL Editor — an online SQL IDE for interacting with your SingleStore databases.
- A schema explorer for your data.
Some of the features we're building include more monitoring tools that give developers more information about their clusters' performance and resource usage over time.
With time, we expect most of the application will be quite interactive and almost none of it can be pre-rendered (we're also not concerned with SEO).
More context: Understanding different types of frontend app rendering
If you're already familiar with static, server side rendering and client-side rendering, you can skip to the next section.
To better understand the limitations we faced while using Next.js, it’s important to understand the difference between these 3 kinds of renders strategies, as well as the ones Next.js is more optimized for.
Client-Side Rendering
This is the most common rendering strategy in React applications. All the render happens on the browser using JavaScript. The first load performance isn't great due to the browser having to download all static assets and then run a lot of JavaScript to render the page. However, there are less moving pieces and it’s easy to maintain and iterate on. If your app doesn’t necessarily need SEO or having a fast first page loading time, using client-side rendering might work well for you.
Server-Side Render
This is probably the “oldest” rendering strategy and it’s the complete opposite of rendering everything in the browser. Instead, everything is rendered on a remote server. This way, the browser will download fully rendered HTML, which will make your page load way faster and is better optimized for SEO bots.
Static Rendering
In the React ecosystem, this type of rendering was introduced by frameworks like GatsbyJS and Next.js. This one is similar to server-side rendering, but instead of generating the HTML on every request, it compiles everything beforehand on build time. It can be very useful for sites where the content doesn’t change that often, like documentation pages, or a corporate website.
It’s also easier to maintain/deploy, because you don’t need to actually run a web server. Since everything is pre-rendered, uploading all your artifacts to a S3 bucket is enough.
Hybrid Rendering
This is the strongest point of Next.js: mixing client + static rendering or client + server rendering. There is no way to only have client-side rendering in a Next.js app.
With a hybrid approach, you can take advantage of both strategies. Serve pre-compiled HTML to the browser and then take advantage of React's hydration on the client and have a fully interactive page with the exact same code.
Why we moved away from Next.js
1. No use for Next.js rendering features
As mentioned before, using static rendering was making our code more complex than it should be, and we weren’t taking advantage of it. For instance, adding any webpack custom integration such as web workers, or monaco-editor was extremely difficult to get right and make it work inside a node.js environment. This was probably one of our biggest pain points, because all the static rendering happens outside the browser.
Also, keeping all these custom extensions to Next.js's base webpack configuration increases the surface of possible bugs and edge cases while upgrading Next.js, which goes against one of the initial premises that made us pick Next.js in the first place.
Note that these 2 integrations are very important to us. We heavily rely on the monaco-editor for our SQL editor feature and web workers to connect directly to a database and execute heavy computational logic.
2. Dynamic Routing
Another limitation was the fact that static rendering doesn’t support dynamic routes that are defined at runtime. Moving to an approach using server side rendering, which supports dynamic routes wasn’t an option for us. We wanted to keep our frontend deploy as simple as: “uploading a couple of files to S3” and not have to maintain web servers.
3. File Based Routing
File-based routing is awesome for simple cases but we needed more flexibility with custom layouts and some other logic that was harder to achieve with the existing solution.
This routing approach was also making our code structure less optimal. For instance, due to security limitations, when using file-based routing you can’t have any other file other than page entries in the directory pages/*
.
4. Local development
We were experiencing some performance development limitations, and we never understood why they were happening. For example, every page transition in local development was taking a long time to happen (2 seconds or more). There are already some open issues reporting the same problem:
Even after removing all of our custom customizations we were still seeing this behavior.
Note: This might not be a problem in the most recent Next.js versions, but it was a big pain point in our local development experience before the migration.
How we migrated from Next.js to a custom setup (with webpack + React Router)
Once we decided to migrate away from Next.js, there were three main things we had to do:
- Write our own custom webpack configuration.
- Adopt a client-side routing library.
- Change our Cloudfront configuration to work with our client-side routing setup.
For #1, we were already overriding most of the Next.js default webpack configuration, so we mainly add to set up prefetching and bundling, as well as configure the dev server.
For #2, we decided to go with react-router 6. We looked at a few routing libraries and react-router 6 seemed like a solid option for us. So far, we've had no regrets about this since it "just works".
For #3, with Next.js we didn't have any special Cloudfront configuration since Next.js generates an index.html per route with subdirectories for each route. All of these files are stored in S3 and served via Cloudfront. With the new setup however, there's only index.html and we had to configure Cloudfront to do redirect to /index.html whenever a specific route is accessed:
resource "aws_cloudfront_distribution" "portal" {
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
}
Parting thoughts
What's working for you today might not work for you tomorrow. Continuously evaluate your tech stack and prioritize migrations accordingly. As your application grows, it's important to take a step back from your current approach in order to be able to come up with better solutions.
Next.js is a really powerful framework and we'll definitely consider it for future projects. However, for this application, we weren’t taking advantage of most of its features, so it made sense for us to move away from it.
Almost one year has passed, and so far we haven’t regretted this decision. We lost some goodies that came for free, like super optimized bundles and pre-fetching. However, maintaining our own solution has allowed us to move faster (local development is much faster too), simplify our code base, and open the way for some complex routing refactors.
As we had to expand this custom solution to other applications, we also implemented our own CLI for managing the development server and frontend builds. This was helpful to consolidate the frontend infrastructure across various projects within SingleStore. We're thinking about writing about this in the future, so stay tuned here on the SingleStore blog.
If you’re an engineer interested in working on these types of problems, or if you’re passionate about delivering an application with great user experience, join us here at Singlestore. We’re hiring!