For the last seven years, DMC's go-to stack for web application development has been a React SPA (single-page application) front-end with an ASP.NET Core REST API. Although that will still be a good fit for some projects, DMC has chosen to expand our toolset and adopt Next.js for developing new web applications.
Why choosing the right framework is important
When developing a new web application, your choice of framework or tech stack can have enormous and wide-ranging implications, and the benefits and consequences of this decision can impact your business years later. These implications include, but are not limited to:
- Performance. Page load times and smoothness of transitions in the UI can both be affected by your framework choice.
- Maintenance. Choosing an out-dated framework, or even choosing a modern framework that's a poor fit for your application, can result in future feature requests taking much longer to implement. I.e. a 1-hour development task could be an 8-hour task in extreme cases.
- Scalability. Some frameworks can limit your options for scaling as your userbase groups. For example a Blazor Server application may make it more difficult to do horizontal scaling. Also, some ORMs will have issues once your data reaches a certain scale.
- Talent. If you choose an unpopular framework, or continue holding onto an outdated one, it can be difficult to hire, train, and retain good developer talent. After all, not many developers want to work on a no-framework PHP app or a ASP.NET Web Forms app in 2024.
- Support. This is where popularity comes into play, because it tends to be easy to find information about how to solve a specific problem with a popular framework, whereas trying to find information on how to solve a specific problem with an unpopular framework can be nearly impossible.
So when developing a new web application, you should look for a framework that is:
- Performant, either in general or at least for your use case
- Has a good developer experience. This helps make maintenance easier and helps attract and retain talent.
- Scalable.
- Popular, or at the very least not unpopular.
With those points in mind, DMC has chosen to add Next.js to our web development toolbelt and officially adopt it for use on new projects. We have done several pilot projects with Next.js and we feel it's a good, balanced approach that will serve a majority of projects well.
What is Next.js?
Next.js is a full-stack React framework that has actually been around a long time (1.0 release was in 2016), but its popularity and their profile in the React community has exploded since they released their big App Router update last year (May 2023). This version of Next.js became the first React framework to implement React Server Components and Server Actions, and as a result the official React documentation even started recommending Next.js for new projects.
Key Features of Next.js
Next.js offers features that make it stand out from other web development frameworks.
Routing
Most web development frameworks offer some sort of routing system, which enables you to connect a URL route (e.g. "/employees/123/edit") to the piece of code responsible for responding to requests for that URL. Next.js offers a particularly effective routing system. It's file-based, which means that requests to your web application will be routed a given React component based on the names of its folders and files.
For example, in the file structure depicted in the below screenshot, a request to "/admin/addCompetency" will be handled by the file "app/admin/addCompetency/page.tsx". This is intuitive and it enforces a disciplined approach to organizing your code files. If a new developer on the team is assigned a task to troubleshoot that "/admin/addCompetency" page, they know exactly where to start.
But that's only a small part of what Next.js' routing system offers us. In the above screenshot, you can see an "error.tsx" file. By including this file in this folder, we automatically wrap our page in an error boundary. Any uncaught exceptions/errors on the "/admin/addCompetency" page will be handling by the component defined in "error.tsx" and a fallback UI will be displayed.
Additionally, we can put a "loading.tsx" file in that folder, and it will cause a fallback UI (loading indicator) to be displayed while we wait for that portion of the page to finish loading. This feature allows us to implement loading UI and streaming with almost no effort.
Next.js' routing system also offers dynamic routes, parallel routes, intercepting routes, and other features that make it easy to compose a complex UI while keeping our codebase simple and well-organized. Read more about it routing in Next.js.
Server Components
I've been using React for web application development since 2016. And when I first started, I distinctly remember thinking, "Okay, but how do we get data from the server into our React components?" The official answer from React back then was 🤷♂️
In 2016, the common way to load data into a React component (without third-party libraries for data fetching) was to do a fetch call from within your component's "componentDidMount" lifecycle method. Honestly, it was kind of gross. With React 16.8 things got a little better with the useEffect hook. And of course, today, in a React SPA the best way to load data from the server into a component is with a third-party library like Tanstack Query (react-query).
But React has never offered a first-class method of getting data from the server and giving it to our React components. Until now. React has introduced the concept of React Server Components (RSC), and Next.js is the React framework to make this feature available. With RSCs, we can write components that run on the server. The components can be defined as async functions, and therefore we can await asynchronous operations in them.
For example, here's a server component for a select list of "categories". I've defined this as an async function, so it can only run on the server, but this allows us to get data directly from our database (which is what db_getCategories() does in this example). Because we await the result, we can guarantee that by the time we reach the next line of our code, we have the data.
import db_getCategories from "@/database/queries/db_getCategories";
import linq from "linq";
export async function CategorySelect() {
const categories = await db_getCategories();
const orderedCategories = linq
.from(categories)
.orderBy((c) => c.Name)
.toArray();
return (
);
}
This results in a very simple component: first we get the data, then we render the data as JSX. No state, no effects, no memoization - just simple, straightforward logic that's easy for a developer to understand.
But what sets React Server Components apart from other server-side rendering solutions is that they can be used to stream parts of the UI to the web browser. So as opposed to the good old days when we used to render the HTML for an entire web page on the server before sending it to the web browser, we can leverage server components along with React Suspense to stream our UI in chunks to the user complete with loading indicators:
Additionally, because the output of these server components is a minimal data format called the RSC Payload, rather than rendered HTML, server components do not have a significant impact on a web application's I/O throughput and therefore should not have a negative impact on scalability in most cases.
This is incredibly powerful. And to someone who's been developing React applications for years, this feels like someone's finally given me the keys to a car that I've just been hotwiring until now.
Server Actions
Server Actions are similar to Server Components in that they're a React feature that Next.js has been the first framework to incorporate. Whereas React Server Components are designed to make loading/fetching data easier, Server Actions are designed for mutating data.
Server Actions are defined similar to any other JavaScript function, except with a "use server" directive added in to signal that they're Server Actions. Then Next.js takes these functions and essentially turns them into HTTP endpoints for us, and by calling these functions from a client component we automatically get an HTTP request sent to that endpoint.
Here's an example Server Action, that takes some simple parameters and makes a corresponding update in the database (db_updateCompetencyForEmployee()) based on those parameters and the authentication information from the request (await getUserSession()).
"use server";
export async function updateCompetencyForEmployee(
competencyId: number,
proficiencyId: number,
learningPreference: boolean,
notes: string | null
) {
const userSession = await getUserSession();
if (!userSession) {
return { success: false, error: "User not logged in" };
}
const onprem_sid = userSession.user.onprem_sid;
if (!onprem_sid) {
return {
success: false,
error: "User session does not contain onprem_sid",
};
}
const employee = await db_getEmployeeByOnpremSid(onprem_sid);
if (isNaN(competencyId) || isNaN(proficiencyId) || !employee) {
return {
success: false,
error: "Invalid competencyId, proficiencyId, or employeeId",
};
}
await db_updateCompetencyForEmployee(
employee.Id,
competencyId,
proficiencyId,
learningPreference,
notes
);
return { success: true };
}
Then from a client component, we can call this Server Action function directly, and under the hood React/Next.js will make an HTTP request to the server with those parameter values in the body of the request.
One way to think of Server Actions is that they've made it far easier to cross the barrier between the client and the server.
A really nifty way to use Server Actions is to provide them to the "action" attribute of a element like so:
export function AddCategoryForm() {
return (
); }
With this simple form, we don't need to manage state at all! This is very similar to plain HTML form handling, where pressing the submit button triggers the values of the form's fields to be bundled up and sent to the provided URL as an HTTP request.
Here's the server action being used in the above example:
"use server";
// ...imports...
export async function addCompetencyCategory(formData: FormData) {
// authorization
if (!(await getUserIsAdmin())) {
redirect(
"/admin?error=" +
encodeURIComponent("You must be an admin to add a category")
);
}
// form data validation with ZOD
const { data, success, error } = await schema.safeParseAsync(formData);
if (!success) {
const errorMessages = error.issues.map((i) => i.message).join("\n");
// redirect with error message if failed validation
redirect("/admin?error=" + encodeURIComponent(errorMessages));
}
const { add_category: name } = data;
// save to database
await db_addCompetencyCategory(name);
revalidatePath("/admin");
// redirect to "success" page
redirect(
"/admin?message=" + encodeURIComponent("Category added successfully")
);
}
An interesting benefit of this approach is the form submission is fully functional even if the user has JavaScript disabled. We don't need that for most projects, but for those few projects where we need to the application to still be functional with JavaScript disabled, this is an enormous benefit. If the user does have JavaScript enabled, though, Next.js automatically transforms the behavior of this simple form to act like a form in a modern SPA, because once the React component tree hydrates the client-side behavior gets enabled. This is called progressive enhancement.
API routes
In addition to its full-stack capabilities, Next.js allows us to define more traditional endpoints via "route handlers". These use the same kind of file-based routing as the rest of the framework, but route handlers allow us to return any kind of HTTP response we want in response to a request. For example, this GET route handler at "/api/projects/{projectId}/expenseReceipts/projectFiles" returns a file that can be downloaded by the browser:
export async function GET(
request: Request,
{ params }: { params: { projectId: string } }
) {
if (!getUserIsLoggedIn()) {
return new Response("Unauthorized", { status: 401 });
}
const acumaticaApiEndpoint = `${process.env.ACUMATICA_API_URL}/ExpenseReceipt/ProjectFiles?projectId=${params.projectId}`;
const acumaticaResponse = await fetch(acumaticaApiEndpoint);
if (acumaticaResponse.ok) {
const bodyAsBuffer = await acumaticaResponse.arrayBuffer();
return new Response(bodyAsBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="projectFiles.zip"`,
},
});
}
}
An advantage of this is that if we have a separate mobile or desktop application that needs to consume endpoints like these, we don't need a separate REST API to serve those applications. It can make sense to have a separate REST API anyway, but it's not strictly necessary.
Faster Load Times
Thanks to server-side rendering (SSR) with React Server Components, initial load times in a Next.js application can be much faster than the initial page load time of a React SPA. Whereas with an SPA we have to wait for the JavaScript bundle (which is usually hundreds of KB and can sometimes be multiple MB) to download before the user sees any UI, in a Next.js app the initial request for the web page gets fully rendered HTML as a response. Taking a current project as an example, loading the home page is only a 7.4 KB download versus 200 KB if I were developing it as an SPA. This tends to result in a much better score for First Contentful Paint (FCP), an important Core Web Vitals performance metric.
And depending on how much content you're able to render on the server for your application, you can even end up seeing the remarkable result of FCP and LCP (Largest Contentful Paint) being the same number! When this happens, it means the very instant that the user first sees content/UI for the web page, they see the entire UI.
Caching
One of the reasons it's easy to develop a highly-performant application with Next.js is because it offers robust caching options. Next.js with app router has four different caching mechanisms, all of which are enabled by default (Next.js 15 will make some of these opt-in).
- Request Memoization - If you make multiple fetch requests to the same URL in multiple places in a React component tree, Next.js will only make the request once
- Data Cache - Same as Request Memoization, but is persistent across multiple requests to our Next.js application, e.g. multiple requests for the same web page
- Full Route Cache - If a given server component or route handler does not use headers or cookies to determine its output, then the HTML output and/or React Server Component Payload (or HTTP response in the case of route handlers) will be cached on the server as static content until something happens to "revalidate" it.
- Router Cache - This is the client-side caching mechanism in Next.js. It's time-based, and by default, if a user has visited a given route in the last 30 seconds, navigating back to that route again within 30 seconds will serve up the same React component tree from the last visit and won't make a request to the server at all.
Automatic Code Splitting
Code splitting involves splitting up your JavaScript bundle so that the user's browser only downloads enough JavaScript to render the currently viewed web page. In other words, if there's only one page in our app that utilizes the react-query package, we can use code splitting to ensure that the web browser doesn't need to download react-query's bundled JavaScript until they visit that specific page - visiting other pages will not trigger that chunk of JavaScript to be downloaded.
This is something we've been able to do in React for a very long time now, but it's fairly involved and is easy to get it wrong. So the fact that Next.js does this automatically for us, and does a good job with it, makes it that much easier to develop an app that delivers as fast and performant an experience as possible.
Improved SEO
Although many of the web application projects we work are not concerned with SEO, some are. For those web applications where good SEO (Search Engine Optimization) is required or at least desired, Next.js helps us achieve better SEO through the following mechanisms:
- Search engine crawlers are better able to fully process the contents of a web page that was rendered on the server than those that are rendered on the client with JavaScript. Next.js enables us to render all our content on the server, and this in turn boosts SEO.
- Performance is a factor in SEO. The faster a web page loads, the higher search engine ranking it will get. Next.js' many mechanisms for improving performance also result in better SEO.
- Because most of your links will be rendered on the server with server components, search engine crawlers will have an easier time indexing all the pages on your web site or web application.
A takeaway here is that, if SEO is important for your web application or website, Next.js is one of your best framework options.
Developer experience is a broad term that covers workplace culture, management process, and workflow, but for the sake of this article we're going to focus on one aspect of developer experience: tools.
If you're a contractor who builds houses, but you restrict your team from using power tools, your employees are not going to enjoy working for you but equally important is the fact that it will take you more hours and money to complete a project. Similarly, the tools a software development team uses can have an enormous impact on their productivity. A toolset that's easy to work with and fits the project's specific needs can result in us shipping more features for our client's web application for less money, which means we deliver more value to our clients.
So let's look at some of the ways that Next.js improves the developer experience.
Simpler code
Two of Next.js' big features - Server Actions and React Server Components - streamline the codebase of a React app in a very big way. I did a demo a while back where I developed the same form two ways: with a plain React component using hooks for state management, and with server components and server actions. The difference was bigger than I'd expected - the traditional React component was 125 lines of code whereas the Next.js version was only 29 lines of code. In the screenshot below, you can see the "plain React" version on the left and the Next.js version on the right.
Comparison between a plain React component and the same component written in Next.js
It should be noted that neither version of this component utilizes any third party libraries - only built-in React features. The version on the right simply uses a handful of custom, reusable components, each only a few lines of very simple code. Both versions of the form component have the same behavior: a loading indicator for the select list of categories, temporarily disabling the submit button while the form submission is pending, and a display for error messages a "form saved successfully" status message.
Not only does the Next.js version of this form component take much less effort to develop, but the code is also much more straightforward - it's purely declarative and contains no imperative logic whatsoever. That almost completely eliminates the potential for buggy behavior (at least on the client-side), but it also makes it very easy to modify and maintain this component in the future. A developer can jump into this code file and understand everything about it with a quick glance.
This has the potential to greatly reduce the amount of effort, and therefore money, required to build a web application. The key word there is potential - an app that requires a large amount of very intricate client-side interactivity may not see as much benefit in terms of the simplicity of the code base and the effort required to develop the app.
Enhanced Developer Tools
Hot reloading has become a staple of front end development. When I save a change to a file in my application, I want to see that change reflected in my web browser without needing to restart the application or even reload my browser. Next.js provides a particularly smooth hot reloading experience. If I save a change to a server component, hot reload will cause that server component to automatically be requested and re-rendered again, updating the UI instantaneously for all intents and purposes. The same is true with client-side components. This full-stack hot reload makes testing changes during development a breeze.
Also, I've found that generative AI tools like GitHub Copilot and ChatGPT are particularly effective at generating code for server components and server actions. Similarly, the server side of a Next.js app will use Node.js APIs and libraries, which generative AI also has an easy time with. So tools like GitHub Copilot can enhance a Next.js developer's productivity even further, which again, helps us deliver more value to our clients when working on a web application.
Rich Ecosystem and Community Support
The old adage about popularity contests being bad does not apply to software development. The more popular a programming language or framework is:
- The easier it is to recruit new developers that are already familiar with it.
- The easier it is to find libraries or packages for that framework/library.
- The easier it is to find answers to problems or questions via a Google search or a StackOverflow question.
Next.js with the app router is picking up in terms of popularity, but it also benefits from the fact that Node.js and React are both incredibly popular. In the 2023 StackOverflow Developer Survey, the two most popular web technologies by a mile were Node.js and React:
The takeaway here is that it is very easy to find support, recruits, and libraries when working with Next.js, more than with any other full-stack web framework. One caveat is that there are a lot of React component libraries/packages out there that have not been updated to work in React Server Components, and may never receive such an update. However, any existing third-party React component will work just fine if used from a client component. Some components will need to be imported in a specific way, but as an extreme example I was able to use a very client-heavy React component from an NPM package that hasn't been updated since 2019 and it worked just fine. There is some misinformation out there around this point. The most you'll have to do to make this work is to do something like the following when important a component:
const ColorThemePicker = dynamic(() => import("./colorThemePicker"), {
ssr: false,
});
Industry Examples
Working with Next.js, we're in good company. The following is a small sample of businesses and organizations that have found success with Next.js.
Internal Benefits
DMC has been piloting Next.js for a year now, it's helped ensure success on several projects:
- DMC has already leveraged Next.js (with App Router) to help a client with a rewrite of their order management web portal. Next.js' streamlined developer experience and robust features allowed us to complete this project well under budget and deliver a web application that our client was happy with.
- We wrote a new internal application (budget tracking app) using Next.js, again with the App Router.
- We rewrote several internal web applications using Next.js, and the result for each application was a more performant application with a smoother UX and a simpler codebase.
Upcoming Features and Roadmap
Next.js represents the cutting edge of web application development technology. Moving forward, they plan to improve integration for React 19 features, as well as ship support for the React Compiler, further improving the productivity of our web development team and allow us to achieve more functionality with less code. Next.js is also continually improving its caching features and is implementing partial prerendering, so this framework aims to become ever more performant as time goes on.
DMC looks forward to leveraging Next.js and the App Router to deliver more value to our clients by efficiently building better web applications.
Ongoing Learning and Adaptation
As with any other technology, Next.js has a learning curve. Next.js is an opinionated framework, and our senior application developers have invested time in learning how to work with those opinions rather than against them. Our next steps are to disseminate that knowledge throughout our team so that we can apply best practices to the web application projects we take on for our clients.
Aside from internally developed training content, the best learning resources for the Next.js App Router are:
Summary of Key Points
To recap, Next.js is an excellent tool for us to utilize on web application projects. Aside from objective technical advantages like performance and SEO, Next.js' core features allow us to develop a given web application with simpler code and less code, and will often produce a codebase that is easier to maintain over time. The end result of all of this is that our clients end up getting more bang for their buck on those projects that are a good fit for Next.js.
To learn more about the benefits of using Next.js for web development, check out the Next.js website, and to learn how to use Next.js check out the official documentation.
If you're looking to develop a new application or rewrite an existing web application, DMC's expert web developers can help you get up and running fast with a high-quality web application that serves your business needs. Read more about our web application development services, and contact us today to explore how DMC can help you.