Israeli Tech Radar

Unleashing tech insights by Tikal’s Experts. Explore the forefront of technology with Tikal, a leading hands-on tech consultancy. Get invaluable insights based on The Israeli Tech Radar, covering advancements, emerging technologies, and industry best practices.

What We Talk About When We Talk About Next.js Best Practices?

Nitzan Shachar
Israeli Tech Radar
Published in
12 min readNov 4, 2024

--

As software developers, we like to think of ourselves as builders, and our entire discourse follows: we keep folders of the construction blueprints and prototyping specs, we start working after the scaffolding has been set up, we acquire our building blocks, we invent tools and master them, and as hands-on engineers, we are also constantly looking for patterns and repetitive procedures so we could automate them to be more productive or simplify them so we could delegate portions of our work.

Popular programming languages nowadays are extremely high-level, and you can build software pieces using low-code or even no-code solutions. Eventually, the hardware running your software will receive its binary instructions one way or another. Still, the person composing that software can choose the level of abstraction they are comfortable working with. The higher level your building blocks are, the more code others write for you. It means you write less code, but it does not necessarily mean your end product contains or depends on less code.

Mostly it’s a good thing — you share the responsibility for that base code maintenance with a bunch of other professionals, and get optimal pieces of software components containing more knowledge and experience baked in than you could probably produce on your own. But, as with anything, once you go just a bit sideways from the happy path highway — you find yourself yet again in a deep dive into the low-level implementation details. If you know the water — you’de likely enjoy the swim. If you’re not used to the water but have the intuition, motivation, and support — you’ll stay afloat and quickly adapt. If you are afraid of the water and aren’t even considering the option of going off the happy path — you are much likelier to get overwhelmed and sink.

Next.js is a framework for building web applications, built on top of React. It’s a curated selection of tooling, preferred APIs and opinionated configuration presets, so truly mastering such a framework actually means mastering React and the web platforms, while understanding the underlying opinions of the opinionated framework, and at least to some minimal extent — share these opinions yourself.

Next.js docs are a remarkably approachable and comprehensive guide to teach you all you need to know about the framework choices and how to maximize its abilities. Thus, when talking about development best practices in relation to a software framework, I find it redundant to re-iterate the framework creators' decisions and motivations. In this article, I’ll go through some of Next.js main features, will try to depict their roots in the web platform and React evolution, and discuss the framework’s implementations with regard to the web platform.

The framework’s prominent feature list includes file-based routing and server-side rendering (which streamlines the usage of React’s server-side components). It also provides high-level APIs with opinionated defaults baked in for data fetching, caching, and optimizing assets. If we look closely at this list we’ll find that a large portion of it are things that web developers have always done, in numerous techniques and with varying levels of success. The framework essentially tries to standardize those techniques and the quality level, while making the developer work easier — providing convention-based presets over details-heavy configuration work, which saves time and effort, and even reduces the decision-making fatigues developers often report.

Server Side Rendering

To take maximum advantage of the Next.js offering, it is encouraged to use primarily server-side components, while using client-side components scarcely, only when it’s really necessary. If you and your team have a solid understanding of how browsers work, how React uses the browser, and what can be done on a server as opposed to the browser — you should find it quite intuitive to maintain Next.js best practices in that regard.

Server components are running on the server, that is — the JavaScript code that specifies them. Granted, the end-user of a frontend application will interact with components on the browser, which means that server components that reached the browser have finished running. They’re now static HTML painted by the browser. This is why server components are optimal for performing server-side operations such as data fetching, file system interaction, and other CPU/memory/storage-heavy operations. However, the server runtime does not overlap the client runtime, and user inputs of most types can be obtained only on the client. These concepts — the environments, runtimes, and the clear boundaries between them must be crystal clear to you and your team when you work in Next.js.

You can of course learn on the fly, since if you try to use the browser APIs on server components you will probably figure it out before production so this trial and error shouldn’t be that costly. However, if you’re on a tight schedule while running into a bunch of buggish behaviors and are way more accustomed to using classic client React — the option to abuse the use client attribute as an easy fix is dangerously tempting. Using Next.js for writing a full-on client React application is a possible thing to do. Still, it is important to realize you are not using the framework as intended, and greatly compromises its strengths, so if there are any reservations regarding tradeoffs worth - this is where you want to seriously start to weigh them. Maybe you are better off using a screwdriver to tighten your bolts rather than hammering them down with a hammer (even if all the cool kids are using the hammer).

Routing

The early SPA days were days of great Ajax excitement with a full-page-load dread. The possibility to engineer complete rich flows without a single full page load emerged, and the flip side of lost trivial web features such as meaningful dynamic metadata for SEO and shareable application state using URLs was answered with tools like react-helmet and react-router.

There are some rare cases of internal tooling or actual simple single-page applications out there, but the de facto standard is some kind of routing system. Modern browsers offer a full-featured native router API, from basic path segments that reflect the application “anatomy” and most often are data-driven drill down, hash routing, “in-memory” routing using the history state object, and an additional layer of search params interface to a more granular persistent application state.

In short, whether you use directly what the browser has to offer or use 3rd party library, whether you develop SPA or MPA — the end-user experience is technology agnostic, and its standards are quite universal. To be perfectly honest, these standards and their implementation in the project you are working on are primarily the job of product managers and UX people. That said, not too few of us (dare I say, if you use Next.js it’s likelier you’re in this group) don’t have the luxury of a dedicated PM to guide us through URL designing best practices. So we should have this basic common sense to design a routing system that will meet the end user expectations.

Next.js file-based routing couples your application URL design with your project file structure. Meaning — defining any route, either flat or deeply nested — requires the mirroring of that URL path using folders and files. I’m very fond of this idea and even find it charming, and implemented it myself in framework-less projects, yet oddly in Next.js, what should have remained a semantic convention became a restrictive technical specification. That said, this restriction applies only to the app directory (pages if you use pre-13V), and also only by defining a page.js or route.js file you make that folder “routable” (publicly accessible as an application view or API endpoint). Other than the content and structure of that folder, and Next.js declares it on itself - the framework is unopinionated with regards to how you structure your file system. You can co-locate code based on a business domain which will probably reflect pretty much the app router file system, or go with the functional division of components/containers/services or any other formation. Just like in vanilla React - files structure is purely a matter of taste.

The framework however does encourage you to intentionally manage the route lifecycle. Next.js route folder is like an abstract interface you can slot in and implement some or all of its methods. There’s a set of “reserved” file names ranging from page.js and layout.js via loading.js, error.js, and even instrumentation.js. Creating such a file will automatically be registered by the framework as a route complementary component and will be used and executed in a certain manner. By default, the framework will create for you generic fallback files so if you don’t explicitly make your own - a user detoured to a non-existing page will see the plain generic Next.js 404 | page not found page. Same for when an unexpected error occurs in your app - you should be intentional with your UX and “save” what’s left of the user flow. These are all UX best practices, and the technology just streamlines the implementation and provides convenient, semantic hooks to plug your solution in.

Caching

Caching can be applied to all types of assets and resources via different techniques, from client-side browser storage, memory storage of the application runtime, server-side cache, and even managed CDNs. Caching will store the requested asset close to where it’s required for future use, enhancing the application performance and saving on operational costs. This is almost unquestionably beneficial for heavy and static assets such as font and media files, but what about dynamic data? what about your SaaS app JavaScript code when you have an urgent hotfix to ship?

Caching is great in some but not all cases and most classic implementations will require you to explicitly use them. However, Next.js will cache as much as it can by default unless you actively opt-out. (Update: Version 15 contains fundamental changes to the caching-defaults settings, see here.) This is one of the cases in which the framework is counter-intuitive to your knowledge and familiarity with the web platform (a recommended read about this thread of thought can be found here and here). In a notoriously controversial act, Next.js even wrapped the native fetch API with a built-in default caching, so caching becomes not only the default but also turns invisible for developers. To top that, running a Next.js app in dev mode switches off caching, which by itself makes a lot of sense, until there’s a bug that’s only reproducible in production, and the “it worked on my machine” defense mechanism kicks in. If you are not a Next.js newbie you probably know that one and how to work with and around this. But even seasoned and experienced web developers, maybe especially those, will be prone to this pitfall. So while this behavior is completely customizable - you can configure revalidation strategies, timings, and even completely opt-out - you will need to remember to actively do so, even if locally it works like you would have expected it to work.

Streaming

Code splitting is nothing new or unique to Next.js. It’s a common standard with a variety of interfaces, from React’s own lazy/suspense API, through webpack chunking strategies, and the straightforward native JavaScript API of dynamic module imports. All aimed to soften page transitioning, components loading, or data fetching, by converting large, synced, thread-blocking operations into multiple smaller, non-blocking async requests. The underlying notion is that web applications should be loaded incrementally to appear as fluent as possible. It became de-facto the canonical UX, so aligning to this pattern in your app provides the end-user with a smooth and mainly familiar loading flow, rather than a clunky behavior that might perceived as completely stuck and lead to user abandonment.

As React developers who are used to thinking in composable components, it’s not a huge conceptual leap to translate this component splitting into an async dependency system, which just needs the extra intentionality of prioritization as to which component requires what to align with the end-user experience you wish to be achieved.

Next.js essentially rebranding this pattern as HTML streaming, and presenting it as a way to overcome some of the traditional SSR shortfalls, like the lengthy and blocking process of SSR up until client-side hydration. However, I do not see a paradigmatical difference between the SSR-originated issues and other fully client-side application flow issues like application bootstrapping and round trips to fetch data.

What shouldn’t be overlooked is that as with anything — code splitting is a tradeoff and has its price, and the smaller the chunks — the more chunks there are to fetch, hence more network requests. For most cases that price is negligible, but these are pretty much the same cases for which the splitting advantages are also not that impactful. When the internet is slow, it’s probably a better experience to see some progress in the app loading, rather than it appearing as completely stuck. Better, but if extremely slow — not that much better. And if you’re on a cellular data budget — will the 10MB of resources your app fetches for the first meaningful screen feel that much cheaper if it comes in smaller pieces? An objectively real improvement would be to reduce the size of your app to the bare minimum, but that has nothing to do with code splitting.

To conclude, code splitting is not a magic technology that solves your app performance issue, but a single pattern among others for how to manage those issues. And, like many other APIs, you must be aware that the quality of the solution you’ll end up with has way more to do with the quality of your design decisions — how will you engineer the UX to be optimal within the technical constraints?

Assets optimization

As discussed, one of the main rules of thumb when it comes to performant web applications is to send as few resources to the browser as possible, first and foremost minimum JavaScript, but also optimal versions of potentially heavy assets such as images and fonts.

Next.js exposes several built-in components that are an extension of HTML elements, such as the img, script, and metadata tags, along with additional convenient abstraction to load fonts, etc. Once again, a lot of what’s on offer is achievable using existing web APIs, but using these configuration-heavy tags can be tedious and potentially error-prone, so Next.js does a great job streamlining the process, picking what’s important and reasonably prioritizing. Unlike other features, the common assets optimizations will rarely have unsuitable use cases or will present a meaningful tradeoff cost.

Conclusion

Next.js shines for a variety of common use cases, most notably e-commerce websites that usually include many heavy media assets, and in which performance and SEO have a direct monetized value. Other suitable use cases are news and blogging websites — content that can be safely generated completely on the server and be served as optimal, lightweight static HTML to the client. These are content-driven sites, in which serving stale data as a placeholder until fresh data is fetched is proper business-wise. You could argue that no web application will suffer from using Next.js and be given a free performance boost, even if you opt-out from every caching opportunity, mark the app root with a use client directive, and only get the structured router and basic server-side prerendering. Some Next.js advocates will stand by this approach.

My outlook derived from the intro of the article in which I argue that nothing is given for free, and depending on a heavy framework with mountains of underlying code to support features you aren’t using — is a cost you must consider. In the words of the Erlang creator — “You wanted a banana but what you got was a gorilla holding the banana and the entire jungle”. More often than not you only really want the banana, and being given a free banana isn’t worth the cost of taking care of a gorilla for the rest of your life. Sure, there are cases in which you won’t even see this as a cost since you use and enjoy so much of it that it makes sense — these aren’t the cases to start asking existential questions of this sort. It’s crucial to recognize your needs and the specific issues you need to solve when selecting a tech stack for your new project, or choosing to migrate an existing one, and more importantly — what are not your issues and which solutions are non-relevant and will end up being a burden on you, your team, and the product health.

During my research for this article, I had in-depth interviews with fellow colleagues in Tikal, who shared with me their extensive conceptual and working knowledge and experience with Next.js. Each of them spent time teaching me so much on the subject, enriching this article tremendously. Special thanks to Roy Kass, Dudi Kaplan, Oryam Nehoray, and Alon Valadji.

--

--

Israeli Tech Radar
Israeli Tech Radar

Published in Israeli Tech Radar

Unleashing tech insights by Tikal’s Experts. Explore the forefront of technology with Tikal, a leading hands-on tech consultancy. Get invaluable insights based on The Israeli Tech Radar, covering advancements, emerging technologies, and industry best practices.

No responses yet