šŸ”„ Mastering NextJs

Tuan Tran VanTuan Tran Van
25 min read

Next.js is a React-based framework that allows you to build server-side-rendered applications with ease. With Next.js, you can create dynamic and fast-loading web pages that are optimized for search engines and social media platforms. Some of the key benefits of using Next.js include:

  • Automatic code splitting for faster page loads.

  • Server-side rendering for improved SEO and performance.

  • Built-in support for CSS modules and styled components.

  • Easy deployment with Vercel, the platform that was built specifically for Next.js.

Today, Iā€™m going to explain some advanced concepts of Next.js that most developers donā€™t know. You can use them to optimize your App and improve the Developer experience.

Key Difference Between Next.js and React

Rendering Methods: client-side vs server-side

When we talk about the rendering method in React, React relies mainly on rendering with the client-side rendering (CSR) method. Therefore, both the logic and the structure of the web page will be handled by the browser (client). Though this method is commonly used, it has some downside effects like slower initial page load.

Next.js, on the other hand, supports both SSR and CSR because it was built on top of React. Web pages are rendered on the server, and both the logic and structure of the page are handled by the server. This enables faster page loading and improves SEO.

Performance Considerations

In terms of performance considerations, Next.js is often preferred because it offers multiple rendering options, including server-side rendering (SSR), static site generation (SSG), Incremental static regeneration (ISR), and client-side rendering(CSR). In contrast, React primarily provides a single rendering approach: client-side rendering.

SEO Implications

React is less SEO-friendly because search engines may struggle to index content that requires Javascript to render.

On the other hand, Next.js is more SEO-friendly than React because it renders content on the server, providing search engines with fully rendered content for easier indexing.

Scaleability and Project Complexity

In terms of scalability and project complexity, Next.js is generally better than React. Next.js provides built-in features that enhance the scalability of your project. These include:

  • Server-side rendering and static site generation for better performance and SEO.

  • A built-in API route features for creating serverless functions seamlessly.

  • A file-based routing system that simplifies the organization of larger projects.

In contrast, with React, you are responsible for setting up and maintaining the structure for scalability. For larger projects, this often requires adding additional tools such as:

  • State management libraries (for example, Redux, Recoil, and so on).

  • Routing libraries (for example, React Router).

These tools are necessary to enhance Reactā€™s scalability and address project complexity, but they also increase the overhead and effort needed to set up and manage the application.

In summary, here is a tabular breakdown:

FactorsReactNext.js
ScalabilityIt is possible, but to increase scalability, it requires additional tools and a custom setupIt is scalable and already has built-in tools that increase the scalability.
PerformanceIt provides only one rendering option, which is client-side rendering (CSR)It offers multiple rendering options, including SSR, SSG, ISR, and CSR.
SEOIt is less SEO-friendly because search engines may struggle to index content that requires JavaScript execution to render.It is more SEO-friendly than React because it renders content on the server, providing fully-rendered HTML to search engines for easier indexing.
Use CaseMostly used in smaller or unique projectsMostly used in large-scale projects and enhances performance and SEO

Dynamic Imports

Dynamic imports are a modern Javascript feature that enables developers to split the code into smaller bundles, permitting the loading of various parts (chunks) of your application on demand. This feature can significantly improve your app performance by reducing load time, especially for large and complex applications.

import dynamic from 'next/dynamic';

// Dynamically import the component
const DynamicComponent = dynamic(
  () => import('../components/DynamicComponent'),
  {
    ssr: false, // Disable server-side rendering for this component
  }
);

const PageComponent = () => (
  <div>
    <p>This is a static content</p>
    <DynamicComponent />
  </div>
);

export default PageComponent;

In the above example, the DynamicComponent will only be loaded when the PageComponent is rendered, thus significantly decreasing the initial loading time for your application.

Understanding Next.js Rendering Strategies ā€” SSG, CSG, SSG, and ISR

One of the advantages of using Next.js is the versatility in regard to how it builds the page on the userā€™s browser. If you can comprehend how these strategies work, you will have an easier time building a faster and more efficient site.

Next.js has four rendering strategies:

  • Server-Side Rendering (SSR)

  • Client-Side Rendering (CSR)

  • Static-Site Generation (SSG)

  • Incremental-Statis Regeneration (ISR)

I will explain each strategy, including the process behind the scenes, these use cases, and the pros and cons.

Server-side Rendering

How it works:

  • The Js file is sent to the server

  • When the user (browser) requests, the server will run the getServerSideProps() function (Next 12) or fetch(ā€˜https://...', { cache: ā€˜no-storeā€™ }); (Next 13)

  • After the data is fetched, it will be built on the server (including the data from the API)

  • The server will send the HTML to the userā€™s browser.

Use cases:

  • if your site data is updated frequently

  • At the same time, SEO is an essential factor as well

Pros

  • No loading

  • Real-time data

  • Good for SEO

  • It can be used for personalized content

Cons

  • The user needs to wait for the HTML to be built on the server side

  • Too much burden on the server => every userā€™s request, the server needs to be rebuilt.

Client-side Rendering

How it works

  • Build Process => The HTML is sent to the server

  • When the user requests, the server sends the HTML file, and then the client (browser) requests the data from the API server.

  • At the time the client is requesting the data from the API server, the browser will display the loading state (in most cases)

  • After the data is fetched from the API server, the loading state will be turned off, and the screen will be updated with the data from the API

Use case:

  • if your siteā€™s data is updated frequently.

  • SEO is not an essential factor.

Pros

  • Real-time data

  • It can be used to personalize content

  • The burden is not too big for the server

Cons

  • There will be a loading state

  • Not good for SEO => Since HTML is built from the server, it does not include the data from the API

Static-Site Generation (SSG)

How it works:

  • The build and fetch data processes happen at the same time => getStaticProps() function (Next 12) or fetch() function (itā€™s set as default in Next 13) is running at build time

  • After the HTML + JSON is built (the data from API is included), it will be sent to the server.

  • When the user (browser) requests, the server will send the HTML + JSON file, so the user doesn't need to wait (no loading).

Use case:

  • if your site data is definite (fetch one, and thatā€™s it)

  • Example: site for Al-Qurā€™an, logs/history data, or old archived documents ā€” which wonā€™t be changed no matter what

Pros

  • Overall, the fastest method

  • No loading

  • The burden is not too big for the server

  • Good for SEO

Cons

  • Donā€™t have the trigger to update the data from API(fetch once, and thatā€™s it) unless itā€™s redeployed

  • It canā€™t be used as personalized content => because this method doesn't have any way to update the built HTML file from the server (so whenever the user requests, it will remain the same)

Incremental Static Regeneration (ISR)

How it works:

  • Pretty much the same with SSG, but with the capability to update the data

  • If you set the revalidated data on the getStaticProps() function (Next 12) or fetch(ā€˜https://.../data', { next: { revalidate: 10 } }); (Next 13), The server will revalidate the data from the API and see whether there is any change.

  • If there is any update from the API (DB), then the built HTML file will be updated (the new one will override the current one)

  • But it will only be updated after the set time in revalidate props time is passed
    Example: If you set revalidate: 10 => It will do the revalidation and update the HTML after 10 seconds have passed.

Use case:

  • Best practice for most static sites that donā€™t need a real-time update

Pros

  • Basically, itā€™s SSG (fast + good for SEO!) but with an additional tweak to be able to update the siteā€™s data

Cons

  • It canā€™t be used as personalized content.

  • It canā€™t be used if you have a real-time feature on your site.

How to Client-Side-Render a Component in Next.js

Whether something in Next.js is client-side or server-side rendered can be easily determined. We will work with this simple component:

<>
  <p>Hello World!</p>
</>

Using Next.js default server-side-rendering, the generated HTML looks like this:

You can see the componentā€™s XML is rendered on the server.

Meanwhile, using client-side-rendering, the initial HTML response looks like this:

Of course, the ā€œHello World!ā€œ still appears in the DOM, but it takes some time because it is rendered through client-side Javascript.

Here are 3 ways to achieve CSR in Next.js:

Method 1: Timing with useEffect

If you use Next.jsā€™s new App Router, every component is a server component by default and canā€™t use React hooks. Therefore, we declare it as a client component on top by stating ā€œuse clientā€œ.

'use client'
import { useEffect, useState } from 'react'

export default function Index() {
  const [isMounted, setIsMounted] = useState(false)

  useEffect(() => {
    setIsMounted(true)
  }, [])

  if (!isMounted) {
    return <p>loading...</p>
  }

  return (
    <>
      <p>Hello World!</p>
    </>
  )
}

Method 2: Dynamic components

Using a dynamic import, a component can also be rendered only on the client side. The reason is simple: The component is imported once the wrapping component is rendered ā€” therefore, all the work is happening on the client.

import dynamic from 'next/dynamic'

const HelloWorld = dynamic(() => import('../components/HelloWorld'), {
  ssr: false,
})

export default function Index() {
  return <HelloWorld />
}

This approach works both for clients and for server-side components.

Method 3: Use the window object (Only Pages Router)

Hint: This approach doesnā€™t work in the new App Router

This trick is so simple: When we render something on the server, the window object isnā€™t available in our code. Why? well, because the window object is exclusive to the browser, of course.

Since we can access this special object in Next.js, we check if we are on the server or not. Therefore, we can save if server-side rendering is happening in a variable like this:

const SSR = typeof window === 'undefined'

SSR is true if server-side-rendering of our JSX is happening. To only client-side render something, use this variable:

const SSR = typeof window == 'undefined'

export default function Index() {
  return <>{!SSR ? <p>Hello World!</p> : null}</>
}

Serverless Functions in Next.js

Serverless functions are a great way to implement backend functionality without managing a server. Next.js provides built-in support for serverless functions using the API directory.

To create a serverless function in Next.js, create a file in the pages/api directory with the following format.

// pages/api/hello.js
export default function handler(req, res) {
  res.status(200).json({ message: 'Hello, world!' });
}

This function will respond to requests to /api/hello with a JSON message.

You can also use serverless functions to handle data from forms or other input fields. Hereā€™s an example of a serverless function that handles a form submission:

// pages/api/contact.js
import nodemailer from 'nodemailer';

export default function handler(req, res) {
  const { name, email, message } = req.body;

  // Send email using nodemailer
  const transporter = nodemailer.createTransport({
    service: 'gmail',
    auth: {
      user: 'your_email@gmail.com',
      pass: 'your_password',
    },
  });

  const mailOptions = {
    from: email,
    to: 'recipient_email@example.com',
    subject: `New message from ${name}`,
    text: message,
  };

  transporter.sendMail(mailOptions, (error, info) => {
    if (error) {
      console.log(error);
      res.status(500).send('Error sending message');
    } else {
      console.log(`Email sent: ${info.response}`);
      res.status(200).send('Message sent');
    }
  });
}

In this example, we are using a nodemailer library to send an email with the form data. This serverless function can be triggered by a form submission in your application.

You must use middleware like this in Next.js

Middleware in Next.js is often overlooked, but itā€™s a game-changer once you understand its potential. If you are not using it yet, you might be missing out on one of the most powerful tools Next.js has to offer.

What is middleware in Next.js?

Letā€™s break down the essentials of middleware in Next.js:

  • Itā€™s simply a function - at its core, middleware is just a function

  • Executed on the edge - middleware runs on the edge, closer to the user

  • Runs for specified pages - you decide which pages it affects through middlewareā€™s configuration.

  • Executes before page load - middleware runs before the user receives the page.

  • Takes in the request - It accepts the pageā€™s GET request object as a parameter.

  • Middleware doesnā€™t impact the rendering pattern, and most importantly, it doesn't interfere with the pageā€™s rendering approach.

This image perfectly illustrates the concept of middleware from a networking perspective.

The problem

Imagine you have a ā€œMy Profileā€œ page that should only be accessible to authenticated users. If someone isnā€™t logged in, we want to redirect them to the Sign-In page.

How do we handle this? Checking for a token in the local storage or a NextAuth session on the client side might seem straightforward, but hereā€™s the catch:

  • User experience suffers ā€” An authenticated user has to wait for the page to load and the client-side code to execute before being redirected. During this delay, they see a loading state, which is less than an idea. Even authenticated users end up seeing this loading state unnecessarily, leading to a frustrating user experience.

  • Bundling and efficiency issues ā€” Using client-side checks means adding extra codes to all private pages. This approach can work in simple applications but becomes inefficient if additional libraries are required, resulting in a larger bundle size.

Alternatively, you might consider handling the session check on the server side. While this can streamline the process, it forces every protected page to become dynamic, which presents a challenge if that is not your goal.

Now, imagine if there was a way to detect user authentication as soon as they request the page and redirect them to the sign-in page without impacting the rendering pattern. Thatā€™s where an efficient solution comes into play.

Once I discovered the power of middleware, I immediately integrated it into my project. But before diving into the code, let me clarify a few key points. The NextAuth library conveniently places all relevant information from the token directly into the req object, allowing us to access it effortlessly via req.nextauth.token . This means I can extract not only authentication details but also user roles and authentication statuses.

Now, letā€™s take a look at how this all comes together:

import { withAuth } from "next-auth/middleware";

const authRoutes = [AppRoutes.signIn, AppRoutes.signUp, AppRoutes.forgotPassword]

export default withAuth(
  async function middleware(req) {
    const user = req.nextauth.token?.user
    const isSigned = Boolean(user)
    const isResetPasswordRoute = req.nextUrl.pathname.startsWith(
      AppRoutes.resetPassword("")
    )
    const isAuthRoute = authRoutes.includes(req.nextUrl.pathname) || isResetPasswordRoute
    const isAdminRoute = req.nextUrl.pathname.startsWith("/admin")
    const isSubscriptionPlansRoute = req.nextUrl.pathname === AppRoutes.subscriptionPlans
    const isAdminUser = req.nextauth.token?.user.role === USER_ROLE.ADMIN
    const accountStatus = req.nextauth.token?.user.accountStatus ?? ""
    const isPaid = accountStatusesWithAppAccess.includes(accountStatus) || isAdminUser
    const hasSubscription =
      accountStatusesThatHasSubscription.includes(accountStatus) || isAdminUser
    const isMustGoPlansRoute = !hasSubscription && isSigned
    const isRenewSubscriptionRoute = req.nextUrl.pathname === AppRoutes.renewSubscription
    const mustGoRenewSubscription = hasSubscription && !isPaid && isSigned

    // if user have trial or subscription, but by some reason it is paused then redirect to renew it
    if (mustGoRenewSubscription && !isRenewSubscriptionRoute) {
      return NextResponse.redirect(new URL(AppRoutes.renewSubscription, req.url))
    }

    // if user shouldn't go to renew subscription, then restrict access to it
    if (!mustGoRenewSubscription && isRenewSubscriptionRoute) {
      const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn

      return NextResponse.redirect(new URL(redirectRoute, req.url))
    }

    // if user signed and has no subscription or trial then he must go and choose plan
    if (!isSubscriptionPlansRoute && isMustGoPlansRoute) {
      return NextResponse.redirect(new URL(AppRoutes.subscriptionPlans, req.url))
    }

    // if user shouldn't go to chose plan, then restrict access to it
    if (isSubscriptionPlansRoute && !isMustGoPlansRoute) {
      const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn

      return NextResponse.redirect(new URL(redirectRoute, req.url))
    }

    // if user signed and paid and trying to go auth route redirect main
    if (isAuthRoute && isSigned && isPaid) {
      return NextResponse.redirect(new URL(AppRoutes.main, req.url))
    }

    // if not signed user trying to go to protected route redirect sign-in
    if (!isSigned && !isAuthRoute) {
      return NextResponse.redirect(new URL(AppRoutes.signIn, req.url))
    }

    // if user is not admin and trying to go admin route redirect main or sign-in
    if (isAdminRoute && !isAdminUser) {
      const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn

      return NextResponse.redirect(new URL(redirectRoute, req.url))
    }
  },
  {
    secret: envServer.NEXTAUTH_SECRET,
    callbacks: {
      authorized: () => true,
    },
  }
)

Uh-huh, functions seem a bit too large, doesn't it?

Iā€™m well aware of that, and I recognize the need for improvement. Inspired by a solution I encountered in a Vue.js project, I set out to create something similar.

Advanced Approach

I was thinking about the most convenient way for me to handle whether the user can access the route or not. I have the same condition for several routes, but sometimes routes have the same condition as another one but with one more condition. Also, some conditions have more priority than others, so we need to handle this too.

Would it be great to define conditions for each route separately? Letā€™s try to do this.

First of all, letā€™s create the desired structure. I prefer an array of objects with routes and conditions, something like this:

[
  {route: "/onboarding", condition: (token: JWT, url: string) => {}},
  {route: "/profile", condition: (token: JWT, url: string) => {}}
]

Ok, it works, but what should this condition return?

In case the condition needs to redirect the user, then it must return a redirect.

Otherwise, let's return just null.

[
  {
    route: "/onboarding", 
    condition: (token: JWT, url: string) => {
      if (!token?.user) return null;

      return NextResponse.redirect(new URL("/sign-in", url))    
    }
  },
  {
    route: "/profile", 
    condition: (token: JWT, url: string) => {
      if (!token?.user) return null;

      return NextResponse.redirect(new URL("/sign-in", url))    
    }
  },
]

Great! It works, but we have 2 issues here.

  1. How are we gonna run it?

  2. The same condition is duplicated

Letā€™s start with the first one.

import type { NextRequestWithAuth } from "next-auth/middleware";

export type RouteConfig = {
  condition: (token: JWT) => NextResponse<any> | null;
  url: string;
};

export function runRoutesMiddleware(
  req: NextRequestWithAuth,
  config: RouteConfig[]
): NextResponse<any> | null {
  const currentRouteConfig = config.find((route) =>
    matchPath(route?.url, props?.nextUrl)
  );

  if (!currentRouteConfig) return null;

  return currentRouteConfig.condition(req.token);
}

export default withAuth(
  async function middleware(req) {
    return runRoutesMiddleware(req, routesRulesConfig);
  },
  {
    secret: env.NEXTAUTH_SECRET,
    callbacks: {
      authorized: () => true,
    },
  }
);

Here, I use a small hack called ā€œmatchPathā€œ. Itā€™s the same function that we have in the react-router. but returns a boolean if the URL matches the pattern. matchPath(ā€œ/user/:idā€, ā€œuser/123ā€) -> true.

Letā€™s solve the duplicated condition issue by separating the condition from the config. Also, we need to have some reusable functions that will allow us to create complex functions from small pieces. Let's call this small function ā€œRuleā€œ. It will have the same type we saw before for condition.

type Rule = (token: JWT) => NextResponse<any> | null;

We need some functions to call these rules until it gets a response from one of them. Here it is.

export function executeRules(
  rules: Rule[],
  url: string, 
  token: JWT,
  ruleIndex: number = 0
): ReturnType<Rule> | void {
  if (ruleIndex > (rules?.length || 0) - 1) return;

  const result = rules?.[ruleIndex]?.(token, url);

  if (!result) {
    return executeRules(rules, url, token, ruleIndex + 1);
  } else {
    return result;
  }
}

And adjust our runRoutesMiddleware function

export function runRoutesMiddleware(
  req: NextRequestWithAuth,
  config: RouteConfig[]
): NextResponse<any> | void {
  const currentRouteConfig = config.find((route) =>
    matchPath(route?.url, props?.nextUrl)
  );

  if (!currentRouteConfig) return;

  return executeRules(
    currentRouteConfig?.rules, 
    req.url, 
    req.nextauth.token
  );  
}

Letā€™s create our rule

export const isAuthenticatedRule: Rule = (token, url) => {
  if (!token?.user) return null;

  return NextResponse.redirect(new URL("/sign-in", url))    
}

Then use it in our config

[
  {
    route: "/onboarding", 
    rules: [isAuthenticatedRule]
  },
  {
    route: "/profile", 
    rules: [isAuthenticatedRule]
  },
]

Thatā€™s it! Now we have a very simple flow for creation, editing, deletion and reprioritisation of our rules for every page. Sounds good, doesnā€™t it?

Key Considerations for Next.js App Router Files

The Next.js App Router, introduced in Next.js App 13, represents a shift from the previous pages directory approach. It uses folders in the app directory to define routes.

This new system supports layout, loading states, and error handling at the route level. It also allows the seamless mixing of client and server components, offering improved performance and a more flexible development experience for the first-load approach.

Now, letā€™s dive into each of these special files by checking out this article.

When Should I Use Server Action (Next.js 14)

I started experimenting with the new Next Server Actions, I quickly learned to love it. They felt like a clean and efficient solution for most of my server requests.

But as I started to play with it and implement it more, I started noticing I was introducing performance issues, unnecessary roundtrips between the client and the server, and even some dangerous API security issues.

I quickly learned that Server Actions werenā€™t always the most efficient choice, especially for straightforward data fetching.

So, letā€™s dive in. In the next few paragraphs, I will explain the different use cases for Server Actions and show you how they can be a game changer in your Next.js app, especially when used in the right context.

What are Server Actions

Server Actions (soon to be named Server Functions in React 19) are a relatively new feature introduced by React to simplify data handling and move more logic to the server side. Next.js has quickly incorporated its own twist in Next.js 14, offering good complementarity with Server-Side Components.

In the following lines, we will focus only on the Next.js implementation:

When to use Server Actions

  • Event handling / Performing Database Mutations

    Server actions allow you to perform server operations and database mutations securely without exposing database logic or credentials to the client. They drastically reduce and simplify your code because they remove the need to write a specific API route for your operations.

      'use server';
      export async function handleServerEvent(eventData: any) {
        // Process any server event
        const res = await someAsyncOperation(eventData);
        if (!res.ok) {
          throw new Error('Failed to handle event');
        }  
        return { res, message: 'Server event handled successfully' };
      }
    
  • Handling form submissions

    Similar to the first point, Server Actions are particularly useful for processing form inputs that need server-side handling. They provide a straightforward way to handle form data on the server, ensuring data validation and integrity without exposing the logic to the client and without having to implement elaborate API endpoints.

      'use server';
      export async function handleFormSubmit(formData: FormData) {
        const name = formData.get('name') as string;
        const email = formData.get('email') as string;
        const message = formData.get('message') as string;
        // Process the form data
    
        const res = await saveToDatabase({ name, email, message });
        if (!res.ok) {
          throw new Error('Failed to process the form data');
        }  
        return { res, message: 'Form submitted successfully' };
      }
    
  • Fetching data from client components

    Server Actions can also be useful for quick data fetching, where a clean developer experience (DX) is crucial. It can simplify the fetch process by typing data access directly to a component without the need for intermediary API layers. Moreover, when using Typescript, Server Actions make using types seamless because everything is within the same function boundary.

      // Simple server action to fetch data from an API
      'use server';
    
      export async function fetchData() {
        const res = await fetch('https://api.example.com/data');
        if (!res.ok) {
          throw new Error('Failed to fetch data');
        }
        return res.json();
      }
    

    Working with Next.js and its server component, you already have this very practical way of using server-side code to fetch data and pre-render the page on the server.

    But Server Action now introduces a brand new way to also do that from your client-side components! It can simplify the fetch process by typing data access directly to the component that needs it, without the need to use useEffects hooks or client-side data fetching libraries.

    Moreover, when using Typescript, Server Actions make typing seamless because everything is within the same function boundary, providing a great developer experience overall.

Potential Pitfalls with Server Actions

  • Donā€™t use server actions from your server-side components

    The simplicity and great DX of Server Actions could make it tempting to use them everywhere, including from a server-side component, and it would work! However, it doesn't really make any sense. Indeed, since your code is already running on the server, you already have the means to fetch anything you need and provide it as props to your page. Using Server Actions here would delay data availability as it causes extra network requests.

    For client-side fetching, Server Actions might also not be the best option. First of all, they always automatically use POST requests, and they can not be cached automatically like a GET request. Secondly, if your app needs advanced client-side caching and state management, using tools like TanStack Query (React Query) or SWR is going to be way more effective for that. However, I havenā€™t tested it myself yet, but itā€™s apparently possible to combine both and use TanStack Query to call your server actions directly.

  • Server Actions Do Not Hide Requests

    Be extremely careful when using server actions for sensitive data. Server Actions do not hide or secure your API requests. Even though Server Actions handle server-side logic, under the hood, they are just another API route, and POST requests are handled automatically by Next.js.

    Anyone can replicate them by using a Rest Client, making it essential to validate each request and authenticate users appropriately. If there is sensitive logic involved, ensure you have proper authentication and authorization checks within your Server Actions.

    Note: Additionally, consider using the very popular next-safe-actions package, which can help secure your actions and also provide type safety.

  • Every Action Adds Server Load

    Using Server Actions might feel convenient, but every action comes at a cost. The more you offload onto the server, the greater the demand for server resources. You may inadvertently increase your appā€™s latency and cloud cost by using Server Actions when client-side processing would suffice. Lightweight operations that could easily run on the client, like formatting dates, sorting data, or managing small UI state transitions, should stay on the client side to keep your server load minimal.

  • Classic API Routes Might Be More Appropriate

    There are cases when sticking with traditional API routes makes more sense, particularly when you need your API to be accessible to multiple clients. Imagine if you need the same logic for both your web app and a mobile app, duplicating the same Server Action logic into an API route will only double the work and maintenance. In these situations, having a centralized API route that all clients can call is a better solution, as it avoids redundancy and ensures consistency across your different clients.

  • Next.js Dependency and the Moving Target

    Itā€™s important to note that Server Actions are closely integrated with Next.js, and both Next.js and React are evolving rapidly. This pace of development can introduce compatibility issues or breaking changes as these frameworks continue to update. If your application prioritizes stability and long-term support, relying heavily on cutting-edge features like Server Actions could result in unwanted technical debt. Weighing the stability of traditional, well-established methods against the appeal of new features is always advisable.

How I Made a Next.js App Load 10x Faster

Ditch Useless useEffect Calls šŸš«

Letā€™s talk about useEffect()ā€” the go-to hammer for devs who see every problem as a nail.

I had components using useEffect() for things that could be done statically or with server-side rendering.

Removing unnecessary useEffect calls instantly reduced my client-side Javascript loads.

The Wrong Way

This forces the client to fetch the data after the page loads. Slow and unnecessary.

The Right Way (Server Components FTW)

No useEffect(), no state management, just pure server-side rendering (SSR). The page loads with data pre-fetched.

Use Image Optimization (next/image) šŸ“·

If you are still using <img> tags like itā€™s 2010, you are missing out.

I had multiple large images slowing down my page.

Switching to Next.jsā€™s next/image reduced my Largest Contentful Paint (LCP).

The Old Way (Bad Performance)

This loads the full image, even if the user is on a small screen. Wasteful

The Next.js Way

This automatically:

  • Optimizes image size

  • Serves WebP format when possible

  • Lazy-loads non-critical images

One area of confusion is how to handle images in Next.js. The process differs depending on whether you are working with local or remote images.

For local images, you donā€™t need to specify a width and height. Next.js will automatically identify the dimensions. Simply import the image and render it using the next/img component.

import Image from "next/image";
import localImage from "public/hoy.png";
export default function MyComponent() {
  return <Image src={localImage} alt="Local Image" />;
}

For remote images, you need to provide a blur placeholder and specify the width and height to prevent layout shifts. You can use the placeholder=blur prop to show a blurred version of the image until the full image loads.

To generate the blur data URL for remote images, you can use the sharp and placeholder packages:

import Image from "next/image";
import getBase64 from "./utils/getBase64";
export default async function MyComponent() {
  const blurDataUrl = await getBase64(remoteImageUrl);
  return (
    <Image
      src={remoteImageUrl}
      width={600}
      height={600}
      alt="Remote Image"
      placeholder="blur"
      blurDataURL={blurDataUrl}
    />
  );
}

The getBase64 utility function fetches the remote image, converts it to an ArrayBuffer, and then generates the base64 representation using the placeholder package.

Reduce Javascript (Move Logic to the Server)

Every extra KB of Javascript adds execution time on the client.

The tricks.

Offload work to the backend or Edge functions.

I used to process data on the client, which was dumb because the same logic could run on the server because sending the response.

Bad: Processing on the Client

This means fetching everything first and then filtering on the client. Unnecessary load.

Good: Processing on the Server

Now, only relevant data reaches the client.

Less Javascript = faster rendering

Use Static Generation When Possible (getStaticProps)

If your data doesn't change often, donā€™t fetch it dynamically.

Instead, pre-generate pages at build time using getStaticProps().

export async function getStaticProps() {
  const res = await fetch("https://api.example.com/posts");
  const posts = await res.json();

  return { props: { posts }, revalidate: 60 }; // Refresh every 60s
}

export default function Blog({ posts }) {
  return <div>{posts.map((p) => <p key={p.id}>{p.title}</p>)}</div>;
}

This generates HTML ahead of time, meaning zero wait time when users visit the page.

Optimize Fonts & Reduce CLS (Cumulative Layout Shift)

Google hates layout shifts.

If your font loads late, users will see a ā€œflash of invisible textā€œ (FOIT).

I fixed this by:

  • Using system fonts (sans-serif, serif)

  • Preloading custom fonts

  • Setting font-display: swap

This prevents text from ā€œjumpingā€œ when fonts load. Smooth experience.

Conclusion

In this article, we have covered several advanced Next.js concepts. By mastering these concepts, you will be able to build powerful, performant web applications with Next.js. Whether you are building a small blog or a large-scale e-commerce platform, Next.js has the tools and features you need to deliver a seamless user experience.

References

https://www.freecodecamp.org/news/nextjs-vs-react-differences/

https://mrizkiaiman.medium.com/understanding-next-js-rendering-strategies-2023-ssr-csr-ssg-isr-9ffb792cf757

https://javascript.plainenglish.io/next-js-client-side-rendering-56a3cae65148

https://blog.devgenius.io/advanced-next-js-concepts-8439a8752597

https://blog.stackademic.com/you-must-use-middleware-like-this-in-next-js-64d59bb4cd59

https://yohanlb.hashnode.dev/when-should-i-use-server-action-nextjs-14?ref=dailydev

https://medium.com/@sanjeevanibhandari3/how-i-made-my-next-js-app-load-10x-faster-and-you-can-too-30a8b6c86d9c

https://aryalskanda1.medium.com/5-tips-and-tricks-to-make-your-life-with-next-js-14-easier-f272bb52537e

0
Subscribe to my newsletter

Read articles from Tuan Tran Van directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tuan Tran Van
Tuan Tran Van

I am a developer creating open-source projects and writing about web development, side projects, and productivity.