Optimizing Next.js Applications With Nx

Optimizing Next.js Applications With Nx

In this article, we will go through how to optimize and build a high-performance Next.js application using Nx and its rich features. We will go through how to set up an Nx server, how to add a plugin to an existing server, and the concept of a monorepo with a practical visualization.

If you’re a developer looking to optimize applications and create reuseable components across applications effectively, this article will show you how to quickly scale your applications, and how to work with Nx. To follow along, you will need basic knowledge of the Next.js framework and TypeScript.

What Is Nx?

Nx is an open-source build framework that helps you architect, test, and build at any scale — integrating seamlessly with modern technologies and libraries, while providing a robust command-line interface (CLI), caching, and dependency management. Nx offers developers advanced CLI tools and plugins for modern frameworks, tests, and tools.

For this article, we will be focusing on how Nx works with Next.js applications. Nx provides standard tools for testing and styling in your Next.js applications, such as Cypress, Storybook, and styled-components. Nx facilitates a monorepo for your applications, creating a workspace that can hold the source code and libraries of multiple applications, allowing you to share resources between applications.

Why Use Nx?

Nx provides developers with a reasonable amount of functionality out of the box, including boilerplates for end-to-end (E2E) testing of your application, a styling library, and a monorepo.

Many advantages come with using Nx, and we’ll walk through a few of them in this section.

  • Graph-based task execution
    Nx uses distributed graph-based task execution and computation caching to speed up tasks. The system will schedule tasks and commands using a graph system to determine which node (i.e. application) should execute each task. This handles the execution of applications and optimizes execution time efficiently.
  • Testing
    Nx provides preconfigured test tools for unit testing and E2E tests.
  • Caching
    Nx also stores the cached project graph. This enables it to reanalyze only updated files. Nx keeps track of files changed since the last commit and lets you test, build, and perform actions on only those files; this allows for proper optimization when you’re working with a large code base.
  • Dependency graph
    The visual dependency graph enables you to inspect how components interact with each other.
  • Cloud storage
    Nx also provides cloud storage and GitHub integration, so that you can share links with team members to review project logs.
  • Code sharing
    Creating a new shared library for every project can be quite taxing. Nx eliminates this complication, freeing you to focus on the core functionality of your app. With Nx, you can share libraries and components across applications. You can even share reusable code between your front-end and back-end applications.
  • Support for monorepos
    Nx provides one workspace for multiple applications. With this setup, one GitHub repository can house the code source for various applications under your workspace.
Nx for Publishable Libraries

Nx allows you to create publishable libraries. This is essential when you have libraries that you will use outside of the monorepo. In any instance where you are developing organizational UI components with Nx Storybook integration, Nx will create publishable components alongside your stories. The publishable components can compile these components to create a library bundle that you can deploy to an external registry. You would use the --publishable option when generating the library, unlike --buildable, which is used to generate libraries that are used only in the monorepo. Nx does not deploy the publishable libraries automatically; you can invoke the build via a command such as nx build mylib (where mylib is the name of the library), which will then produce an optimized bundle in the dist/mylib folder that can be deployed to an external registry.

Nx gives you the option to create a new workspace with Next.js as a preset, or to add Next.js to an existing workspace.

To create a new workspace with Next.js as a preset, you can use the following command:

npx create-nx-workspace happynrwl \
--preset=next \
--style=styled-components \
--appName=todo

This command will create a new Nx workspace with a Next.js app named “todo” and with styled-components as the styling library.

Then, we can add the Next.js application to an existing Nx workspace with the following command:

npx nx g @nrwl/next:app
Building a Next.js and Nx Application

The Nx plugin for Next.js includes tools and executors for running and optimizing a Next.js application. To get started, we need to create a new Nx workspace with next as a preset:

npx create-nx-workspace happynrwl \
--preset=next \
--style=styled-components \
--appName=todo

The code block above will generate a new Nx workspace and the Next.js application. We will get a prompt to use Nx Cloud. For this tutorial, we will select “No”, and then wait for our dependencies to install. Once that’s done, we should have a file tree similar to this:

📦happynrwl
 ┣ 📂apps
 ┃ ┣ 📂todo
 ┃ ┣ 📂todo-e2e
 ┃ ┗ 📜.gitkeep
 ┣ 📂libs
 ┣ 📂node_modules
 ┣ 📂tools
 ┣ 📜.editorconfig
 ┣ 📜.eslintrc.json
 ┣ 📜.gitignore
 ┣ 📜.prettierignore
 ┣ 📜.prettierrc
 ┣ 📜README.md
 ┣ 📜babel.config.json
 ┣ 📜jest.config.js
 ┣ 📜jest.preset.js
 ┣ 📜nx.json
 ┣ 📜package-lock.json
 ┣ 📜package.json
 ┣ 📜tsconfig.base.json
 ┗ 📜workspace.json

In the 📂apps folder, we’ll have our Next.js application “todo”, with the preconfigured E2E test for the to-do app. All is this is auto-generated with the powerful Nx CLI tool.

To run our app, use the npx nx serve todo command. Once you’re done serving the app, you should see the screen below:

Building the API

At this point, we have set up the workspace. Up next is building the CRUD API that we will use on the Next.js application. To do this, we will be using Express; to demonstrate monorepo support, we will build our server as an application in the workspace. First, we have to install the Express plugin for Nx by running this command:

npm install --save-dev @nrwl/express

Once that’s done, we are ready to set up our Express app in the workspace provided. To generate an Express app, run the command below:

npx nx g @nrwl/express:application --name=todo-api --frontendProject=todo

The command nx g @nrwl/express:application will generate an Express application to which we can pass additional specification parameters; to specify the name of the application, use the --name flag; to indicate the front-end application that will be using the Express app, pass the name of an app in our workspace to --frontendProject. A few other options are available for an Express app. When this is done, we will have an updated file structure in the apps folder with the 📂todo-api folder added to it.

📦happynrwl
 ┣ 📂apps
 ┃ ┣ 📂todo
 ┃ ┣ 📂todo-api
 ┃ ┣ 📂todo-e2e
 ┃ ┗ 📜.gitkeep
 …

The todo-api folder is an Express boilerplate with a main.ts entry file.

/**
 * This is not a production server yet!
 * This is only minimal back end to get started.
 */
import * as express from 'express';
import {v4 as uuidV4} from 'uuid';

const app = express();
app.use(express.json()); // used instead of body-parser

app.get('/api', (req, res) => {
  res.send({ message: 'Welcome to todo-api!' });
});

const port = process.env.port || 3333;
const server = app.listen(port, () => {
  console.log(`Listening at http://localhost:${port}/api`);
});
server.on('error', console.error);

We will be creating our routes inside this app. To get started, we will initialize an array of objects with two key-value pairs, item and id, just under the app declaration.

/**
 * This is not a production server yet!
 * This is only minimal back end to get started.
 */
import * as express from 'express';
import {v4 as uuidV4} from 'uuid';

const app = express();
app.use(express.json()); // used instead of body-parser

let todoArray: Array<{ item: string; id: string }> = [
  { item: 'default todo', id: uuidV4() },
];
…

Next up, we will set up the route to fetch all to-do lists under app.get():

…
app.get('/api', (req, res) => {
  res.status(200).json({
    data: todoArray,
  });
});
…

The code block above will return the current value of todoArray. Subsequently, we will have routes for creating, updating, and removing to-do items from the array.

…

app.post('/api', (req, res) => {
  const item: string = req.body.item;
  // Increment ID of item based on the ID of the last item in the array.
  let id: string = uuidV4();
  // Add the new object to the array
  todoArray.push({ item, id });
  res.status(200).json({
    message: 'item added successfully',
  });
});
app.patch('/api', (req, res) => {
  // Value of the updated item
  const updatedItem: string = req.body.updatedItem;
  // ID of the position to update
  const id: string = req.body.id;
  // Find index of the ID
  const arrayIndex = todoArray.findIndex((obj) => obj.id === id);

  // Update item that matches the index
  todoArray[arrayIndex].item = updatedItem

  res.status(200).json({
    message: 'item updated successfully',
  });
});

app.delete('/api', (req, res) => {
  // ID of the position to remove
  const id: string = req.body.id;
  // Update array and remove the object that matches the ID
  todoArray = todoArray.filter((val) => val.id !== id);
  res.status(200).json({
    message: 'item removed successfully',
  });
});

…

To create a new to-do item, all we need is the value of the new item as a string. We’ll generate an ID by incrementing the ID of the last element in the array on the server. To update an existing item, we would pass in the new value for the item and the ID of the item object to be updated; on the server, we would loop through each item with the forEach method, and update the item in the place where the ID matches the ID sent with the request. Finally, to remove an item from the array, we’d send the item’s ID to be removed with the request; then, we filter through the array, and return a new array of all items not matching the ID sent with the request, assigning the new array to the todoArray variable.

Note: If you look in the Next.js application folder, you should see a proxy.conf.json file with the configuration below:

{
  "/api": {
    "target": "http://localhost:3333",
    "secure": false
  }
}

This creates a proxy, allowing all API calls to routes matching /api to target the todo-api server.

Generating Next.js Pages With Nx

In our Next.js application, we will generate a new page, home, and an item component. Nx provides a CLI tool for us to easily create a page:

npx nx g @nrwl/next:page home

Upon running this command, we will get a prompt to select the styling library that we want to use for the page; for this article, we will select styled-components. Voilà! Our page is created. To create a component, run npx nx g @nrwl/next:component todo-item; this will create a component folder with the todo-item component.

API Consumption in Next.js Application

In each to-do item, we will have two buttons, to edit and delete the to-do item. The asynchronous functions performing these actions are passed as props from the home page.

…
export interface TodoItemProps {
  updateItem(id: string, updatedItem: string): Promise<void>;
  deleteItem(id: string): Promise<void>;
  fetchItems(): Promise<any>;
  item: string;
  id: string;
}
export const FlexWrapper = styled.div`
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #ccc;
  padding-bottom: 10px;
  margin-top: 20px;
  @media all and (max-width: 470px) {
    flex-direction: column;
    input {
      width: 100%;
    }
    button {
      width: 100%;
    }
  }
`;

export function TodoItem(props: TodoItemProps) {
  const [isEditingItem, setIsEditingItem] = useState<boolean>(false);
  const [item, setNewItem] = useState<string | null>(null);

  return (
    <FlexWrapper>
      <Input
        disabled={!isEditingItem}
        defaultValue={props.item}
        isEditing={isEditingItem}
        onChange={({ target }) => setNewItem(target.value)}
      />
      {!isEditingItem && <Button
        onClick={() => setIsEditingItem(true)}
      >
        Edit
      </Button>}
      {isEditingItem && <Button onClick={async () => {
         await props.updateItem(props.id, item);
         //fetch updated items
         await props.fetchItems();
         setIsEditingItem(false)
         }}>
        Update
      </Button>}
      <Button
        danger
        onClick={async () => {
          await props.deleteItem(props.id);

          //fetch updated items
          await await props.fetchItems();
        }}
      >
        Delete
      </Button>
    </FlexWrapper>
  );
}

For the updating functionality, we have an input that is disabled when the isEditingItem state is false. Once the “Edit” button is clicked, it toggles the isEditingItem state to true and displays the “Update” button. Here, the input component is enabled, and the user can enter a new value; when the “Update” button is clicked, it calls the updateItem function with the parameters passed in, and it toggles isEditingItem back to false.

In the home page component, we have the asynchronous functions performing the CRUD operation.

 …
  const [items, setItems] = useState<Array<{ item: string; id: string }>>([]);
  const [newItem, setNewItem] = useState<string>('');
  const fetchItems = async () => {
    try {
      const data = await fetch('/api/fetch');
      const res = await data.json();
      setItems(res.data);
    } catch (error) {
      console.log(error);
    }
  };
  const createItem = async (item: string) => {
    try {
      const data = await fetch('/api', {
        method: 'POST',
        body: JSON.stringify({ item }),
        headers: {
          'Content-Type': 'application/json',
        },
      });
    } catch (error) {
      console.log(error);
    }
  };
  const deleteItem = async (id: string) => {
    try {
      const data = await fetch('/api', {
        method: 'DELETE',
        body: JSON.stringify({ id }),
        headers: {
          'Content-Type': 'application/json',
        },
      });
      const res = await data.json();
      alert(res.message);
    } catch (error) {
      console.log(error);
    }
  };
  const updateItem = async (id: string, updatedItem: string) => {
    try {
      const data = await fetch('/api', {
        method: 'PATCH',
        body: JSON.stringify({ id, updatedItem }),
        headers: {
          'Content-Type': 'application/json',
        },
      });
      const res = await data.json();
      alert(res.message);
    } catch (error) {
      console.log(error);
    }
  };
  useEffect(() => {
    fetchItems();
  }, []);
…

In the code block above, we have fetchItems, which returns todoArray from the server. Then, we have the createItem function, which takes a string; the parameter is the value of the new to-do item. The updateItem function takes two parameters, the ID of the item to be updated and the updatedItem value. And the deleteItem function removes the item matching the ID that is passed in.

To render the to-do item, we map through the items state:

 …
return (
    <StyledHome>
      <h1>Welcome to Home!</h1>
      <TodoWrapper>
         {items.length > 0 &&
          items.map((val) => (
            <TodoItem
              key={val.id}
              item={val.item}
              id={val.id}
              deleteItem={deleteItem}
              updateItem={updateItem}
              fetchItems={fetchItems}
            />
          ))}
      </TodoWrapper>
      <form
        onSubmit={async(e) => {
          e.preventDefault();
          await createItem(newItem);
          //Clean up new item
          setNewItem('');
          await fetchItems();
        }}
      >
        <FlexWrapper>
          <Input
            value={newItem}
            onChange={({ target }) => setNewItem(target.value)}
            placeholder="Add new item…"
          />
          <Button success type="submit">
            Add +
          </Button>
        </FlexWrapper>
      </form>
    </StyledHome>
  );
…

Our server and front end are now set up. We can serve the API application by running npx nx serve todo-api, and for the Next.js application, we run npx nx serve todo. Click the “Continue” button, and you will see a page with the default to-do item displayed.

We now have a working Next.js and Express application working together in one workspace.

Nx has another CLI tool that allows us to view the dependency graph of our application in our terminal run. Run npx nx dep-graph, and we should see a screen similar to the image below, depicting the dependency graph of our application.

Other CLI Commands for Nx

  • nx list
    Lists the currently installed Nx plugins.
  • nx migrate latest
    Updates the packages in package.json to the latest version.
  • nx affected
    Performs the action on only the affected or modified apps.
  • nx run-many --target serve --projects todo-api,todo
    Runs the target command across all projects listed.
Conclusion

As a general overview of Nx, this article has covered what Nx offers and how it makes work easier for us. We also walked through setting up a Next.js application in an Nx workspace, adding an Express plugin to an existing workspace, and using the monorepo feature to house more than one application in our workspace.

You will find the complete source code in the GitHub repository. For additional information about Nx, check out the documentation or the Nx documentation for Next.js.