Starting a Frontend Monorepo: A Practical Tutorial with Nx, Vite, Biome.js, and React

In this article, we’ll build a monorepo for frontend applications. We’ll use: PNPM for efficient package management, Nx as our build tool, Vite for bundling, Vitest as our test runner, Biome.js for formatting and linting, React with TypeScript, and Emotion for styling. You can configure your tech stack as you need while following the tutorial.

We’ll use the following architecture for our applications:

my-org

packages/

apps/

ui/

ui-text

app-one

utils

app-two

Step 1: Initializing the Monorepo with PNPM and Nx

First, ensure you have Node.js (v18 or higher) and PNPM installed. If you don’t have PNPM, you can install it globally:

npm install -g pnpm

Now, let’s create our Nx workspace. We’ll use the create-nx-workspace command, which will set up the basic structure and configuration for us.

pnpm create nx-workspace@latest my-org --preset=react-monorepo --appName=app-one --style=@emotion/styled --nxCloud=skip

Let’s break down this command:

  • my-org: This will be the name of our root folder.
  • --preset=react-monorepo: This tells Nx to configure the workspace for a React monorepo.
  • --appName=app-one: It creates our first application, named app-one, inside the apps directory.
  • --style=@emotion/styled: We are pre-configuring our app to use Emotion for CSS-in-JS.
  • --nxCloud=skip: We’ll skip Nx Cloud for this monorepo, but it’s a great feature for distributed caching in team environments.

We’ll need to answer some questions. You can use these answers:

  • Which bundler would you like to use?
    • Vite
  • Which unit test runner would you like to use?
    • Vitest
  • Test runner to use for end to end (E2E) tests
    • Playwright
  • Would you like to use ESLint?
    • No (we’ll use Biome.js for this)
  • Would you like to use Prettier for code formatting?
    • No (we’ll use Biome.js for this)

After a few seconds, we’ll have our org monorepo initialized:

woliveiras pnpm create nx-workspace@latest my-org --preset=react-monorepo --appName=app-one --style=@emotion/styled --nxCloud=skip

 NX   Let's create a new workspace [https://nx.dev/getting-started/intro]

✔ Which bundler would you like to use? · vite
✔ Which unit test runner would you like to use? · vitest
✔ Test runner to use for end to end (E2E) tests · playwright
✔ Would you like to use ESLint? · No
✔ Would you like to use Prettier for code formatting? · No

 NX   Creating your v21.4.0 workspace.

✔ Installing dependencies with pnpm
✔ Successfully created the workspace: my-org

If you’re using VS Code, you can also create the MCP server config file in the .vscode folder for using Nx:

file .vscode/mpc.json

{
  "servers": {
    "nx-mcp": {
      "type": "stdio",
      "command": "npx",
      "args": [
        "-y",
        "nx-mcp@latest",
        "."
      ]
    }
  }
}

The initial folders in our org monorepo will be:

.
├── apps
   ├── app-one
   ├── index.html
   ├── package.json
   ├── public
   └── favicon.ico
   ├── src
   ├── app
   ├── app.spec.tsx
   ├── app.tsx
   └── nx-welcome.tsx
   ├── assets
   └── main.tsx
   ├── tsconfig.app.json
   ├── tsconfig.json
   ├── tsconfig.spec.json
   └── vite.config.ts
   └── app-one-e2e
       ├── package.json
       ├── playwright.config.ts
       ├── src
   └── example.spec.ts
       └── tsconfig.json
├── nx.json
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── tsconfig.base.json
├── tsconfig.json
└── vitest.workspace.ts

In this tutorial, we’ll not focus on the explaination of the files created by Nx, but you can check the Nx documentation for more details.

Before continuing with the tutorial, let’s check if it’s all working.

Run the following command to start our app-one.

pnpm nx serve app-one

Or by running this command:

pnpm exec nx run app-one:serve

This will start the development server for app-one on port 4200 by default. You can change the port in the apps/app-one/vite.config.ts file if needed.

If all goes well, you’ll be able to access the dev server.

my-org git:(main)  pnpm nx serve app-one

> nx run @my-org/app-one:serve

> vite


  VITE v6.3.5  ready in 323 ms

  Local:   http://localhost:4200/

Step 2: Adding Biome.js to our monorepo

Before creating the app-two and the libraries for our org, we need to finish the setup of the monorepo. We just need to install the Biome.js now.

Just run the following command:

pnpm add -D --save-exact @biomejs/biome

After installing it, we can initialize the Biome.js settings:

pnpm exec biome init

Now we have the biome.json file in our monorepo. We can run pnpm exec biome format --write to fix anything that is not in the standard defined in the biome.json file.

You can also run pnpm exec biome check to check if everything is fine with the code in the monorepo.

Add the Biome.js extension to your editor too. If you’re using VS Code, you can install the Biome extension from the marketplace.

Step 3: Creating a Second Application

One of the main advantages of a monorepo is having multiple applications co-located. Let’s create our second application, app-two. Nx makes this simple with its code generators.

Run the following command from the root of the monorepo:

pnpm nx g @nx/react:app apps/app-two --style=@emotion/styled  --bundler=vite

We’ll also need to answer some questions. You can follow this:

 Would you like to add routing to this application? (y/N) · false
 What unit test runner should be used? · vitest
 Which E2E test runner would you like to use? · playwright
 Which port would you like to use for the dev server? · 5000

We’re using the port 5000 for app-two to avoid conflicts with app-one, which runs on port 4200.

Now, you can up the second application by running:

pnpm nx serve app-two

And you should see the following output:

my-org git:(main)  pnpm nx serve app-two

> nx run @my-org/app-two:serve

> vite


  VITE v6.3.5  ready in 77 ms

  Local:   http://localhost:5000/

Step 4: Creating a Shared Utility Library

Now for the core benefit of a monorepo: code sharing. Let’s create a simple utility library that can be used by both app-one and app-two.

We’ll place our reusable code in the packages directory. Some Nx presets call it libs, but we’ll use packages to keep consistency with what the JavaScript community is used to (because of our past with Lerna). You can use libs instead of packages if you want to. Let’s create that directory first.

mkdir packages

Now, let’s use an Nx generator to create a new TypeScript library called utils.

pnpm nx g @nx/js:lib utils --directory=packages/utils --bundler=none --unitTestRunner=vitest

This command creates a new library in packages/utils. We specified --bundler=none because this simple library doesn’t need to be bundled on its own; it will be consumed as source code by our applications, which Vite will handle. We also configured it with Vitest for testing.

Now, let’s add a simple utility function. Open packages/utils/src/lib/utils.ts and add the following function:

/**
 * Formats a greeting message for the provided name.
 *
 * @param name - The name to include in the greeting.
 * @returns A greeting string in the format: "Hello, {name}! Welcome to our monorepo."
 */
export function formatGreeting(name: string): string {
	return `Hello, ${name}! Welcome to our monorepo.`;
}

Notice that the generator also created an index.ts file (packages/utils/src/index.ts) that exports this module. This is our library’s public API.

We also have a test file here. So, let’s update its content to this:

import { formatGreeting } from "./utils.js";

describe('utils', () => {
  it('should work', () => {
    expect(formatGreeting("Uillaz")).toEqual(
      "Hello, Uillaz! Welcome to our monorepo."
    );
  })
})

You can change Uillaz to your name.

We can run the tests for this project by using this command:

pnpm nx test packages/utils

Step 5: Creating a Shared UI Component Library

Now, we have two applications and one shared utilities library. Let’s create the ui package to share ui components between our apps.

pnpm nx g @nx/react:lib ui-text --directory=packages/ui/text --bundler=vite --unitTestRunner=vitest --style=@emotion/styled
  • @nx/react:lib: We are using the React library generator.
  • --directory=packages/ui/text: This will place our library inside a packages/ui folder, which is good for organization.
  • --bundler=vite: We are using Vite to build this library.

Now, let’s implement the component. Open packages/ui/text/src/lib/ui-text.tsx and replace its content with this:

import styled from "@emotion/styled";
import React from "react";

/**
 * Text component for rendering styled text.
 *
 * @param props - Properties for the Text component
 * @param props.children - Content to be rendered inside the text
 * @param props.color - Text color (CSS color string)
 * @param props.size - Text size: 'small', 'medium', or 'large'
 */
export interface TextProps {
	children: React.ReactNode;
	color?: string;
	size?: "small" | "medium" | "large";
}

/**
 * Styled paragraph element for the Text component.
 * Applies color and font size based on props.
 */
const StyledText = styled.p<Omit<TextProps, "children">>`
  color: ${(props) => props.color || "black"};
  font-size: ${(props) => {
		switch (props.size) {
			case "small":
				return "12px";
			case "large":
				return "24px";
			default:
				return "16px";
		}
	}};
  margin: 0;
`;

/**
 * Renders styled text with customizable color and size.
 */
export const Text: React.FC<TextProps> = (props) => {
  return <StyledText {...props}>{props.children}</StyledText>;
};

export default Text;

Let’s update the tests for this component! But, before anything, let’s add the matchers needed for testing UI components and the types in our monorepo:

pnpm add -D @testing-library/jest-dom @types/react @types/react-dom -w

Now, open packages/ui/text/src/lib/text.spec.tsx and replace its content with this:

import { render } from "@testing-library/react";
import '@testing-library/jest-dom';

import Text from "./text";

describe("Text", () => {
  it("should render children", () => {
    const { getByText } = render(<Text>Hello world</Text>);
    expect(getByText("Hello world")).toBeInTheDocument();
  });

  it("should apply color prop", () => {
    const { getByText } = render(<Text color="red">Colored</Text>);
    expect(getByText("Colored")).toHaveStyle({ color: "rgb(255, 0, 0)" });
  });

  it("should apply size 'small'", () => {
    const { getByText } = render(<Text size="small">Small</Text>);
    expect(getByText("Small")).toHaveStyle({ fontSize: "12px" });
  });

  it("should apply size 'medium' (default)", () => {
    const { getByText } = render(<Text size="medium">Medium</Text>);
    expect(getByText("Medium")).toHaveStyle({ fontSize: "16px" });
  });

  it("should apply size 'large'", () => {
    const { getByText } = render(<Text size="large">Large</Text>);
    expect(getByText("Large")).toHaveStyle({ fontSize: "24px" });
  });

  it("should use default color if none is provided", () => {
    const { getByText } = render(<Text>Default color</Text>);
    expect(getByText("Default color")).toHaveStyle({ color: "rgb(0, 0, 0)" });
  });
});

Now you can run the tests by using the following command:

pnpm nx test packages/ui/text

Step 6: Using Shared Code in Our Applications

Now, let’s use our new utils and ui-text libraries in our app-one.

First of all, let’s add our paths to the settings and configure the TypeScript aliases.

Open the apps/app-one/tsconfig.app.json and add the following content:

{
	"extends": "../../tsconfig.base.json",
	"compilerOptions": {
		"outDir": "dist",
		"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
		"jsx": "react-jsx",
		"lib": ["dom"],
		"types": [
			"node",
			"@nx/react/typings/cssmodule.d.ts",
			"@nx/react/typings/image.d.ts",
			"vite/client"
		],
		"jsxImportSource": "@emotion/react"
	},
	"exclude": [
		"out-tsc",
		"dist",
		"src/**/*.spec.ts",
		"src/**/*.test.ts",
		"src/**/*.spec.tsx",
		"src/**/*.test.tsx",
		"src/**/*.spec.js",
		"src/**/*.test.js",
		"src/**/*.spec.jsx",
		"src/**/*.test.jsx",
		"vite.config.ts",
		"vite.config.mts",
		"vitest.config.ts",
		"vitest.config.mts"
	],
	"include": [
		"src/**/*.js",
		"src/**/*.jsx",
		"src/**/*.ts",
		"src/**/*.tsx",
		"../../packages/**/*.ts",
		"../../packages/**/*.tsx"
  ]
}

In the tsconfig.base.json, add the following content:

{
  "compilerOptions": {
    "composite": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "importHelpers": true,
    "isolatedModules": true,
    "lib": [
      "es2022"
    ],
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "es2022",
    "customConditions": [
      "development"
    ],
    "rootDir": ".",
    "baseUrl": ".",
    "paths": {
      "@my-org/text": [
        "packages/ui/text/src/index.ts"
      ],
      "@my-org/utils": [
        "packages/utils/src/index.ts"
      ]
    }
  }
}

To avoid any future issues with paths, let’s add to the tsconfigPaths for our Vite applications.

Run this command in the root of our monorepo.

pnpm add -D vite-tsconfig-paths -w

And update the file apps/app-one/vite.config.ts.

/// <reference types='vitest' />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig(() => ({
	root: __dirname,
	cacheDir: "../../node_modules/.vite/apps/app-one",
	server: {
		port: 4200,
		host: "localhost",
	},
	preview: {
		port: 4300,
		host: "localhost",
	},
	plugins: [react(), tsconfigPaths()],
	build: {
		outDir: "./dist",
		emptyOutDir: true,
		reportCompressedSize: true,
		commonjsOptions: {
			transformMixedEsModules: true,
		},
	},
	test: {
		name: "@my-org/app-one",
		watch: false,
		globals: true,
		environment: "jsdom",
		include: ["{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
		reporters: ["default"],
		coverage: {
			reportsDirectory: "./test-output/vitest/coverage",
			provider: "v8" as const,
		},
	},
}));

This allows us to import our shared code using a clean path. Like this:

import { Text } from "@my-org/text";
import { formatGreeting } from "@my-org/utils";

When we run the server for the first time, we’ll receive a message to sync the TypeScript settings. You can select Yes, sync the changes and run the tasks.

Run the server:

pnpm exec nx serve app-one

Select Yes:

? Would you like to sync the identified changes to get your workspace up to date?
Yes, sync the changes and run the tasks

It will add the following code in our tsconfig of the app-one:

 "references": [
    {
      "path": "../../packages/utils/tsconfig.lib.json"
    },
    {
      "path": "../../packages/ui/text/tsconfig.lib.json"
    }
  ],

Now, if everything goes well, we have the formatGreeting function and the Text component shared between any application in our monorepo. We can continue the job by adding more modules, more components, and more applications.

Before finishing our step-by-step and going to other points, let’s update the test for the app-one, because we changed how it works.

Open the test file apps/app-one/src/app/app.tsx and update with the following content:

import { render } from "@testing-library/react";
import App from "./app";

describe("App", () => {
  it("should render successfully", () => {
    const { baseElement } = render(<App />);
    expect(baseElement).toBeTruthy();
  });

  it("should display the correct greeting", () => {
    const { getByText } = render(<App />);
    expect(getByText("Hello, Developer! Welcome to our monorepo.")).not.toBeNull();
  });

  it("should render shared Text component", () => {
    const { getByText } = render(<App />);
    expect(getByText("This is a shared component from the ui-text package.")).not.toBeNull();
  });
});

Now we are finished.

Running tests for all applications

If you need to run all the tests in the monorepo, you can use this command:

pnpm exec nx run-many --target=test --all --parallel

We use parallel to make it faster.

Example:

pnpm exec nx run-many --target=test --all --parallel


  nx run @my-org/text:build
  nx run @my-org/text:test
  nx run @my-org/utils:test
  nx run @my-org/app-one:test
  nx run @my-org/app-two:test

Running the build process

If you need to build all the projects in the monorepo, you can use this command:

pnpm nx run-many --target=build --all --parallel

We use parallel to make it faster.

Example:

pnpm nx run-many --target=build --all --parallel


  nx run @my-org/text:build
  nx run @my-org/app-two:build
  nx run @my-org/app-one:build

Conclusion

You’ve successfully built a frontend monorepo, establishing a clean, scalable, and efficient architecture. With a solid foundation of PNPM, Nx, Vite, and Biome.js, your development workflow is now optimized for both speed and consistency.

So, what’s next? This monorepo is the perfect launchpad for more advanced topics. Consider exploring:

  • CI/CD Integration: Set up GitHub Actions or GitLab CI to automatically test and build only the applications affected by your changes using Nx’s affected command.
  • Advanced State Management: Introduce a shared state management solution like Zustand or Redux Toolkit that can be used across your applications.
  • Storybook for UI Components: Integrate Storybook with your ui/text library to document and visually test your components in isolation.
  • Code Generation: Create your own custom Nx generators to scaffold new components or modules with your team’s specific conventions.

Congratulations on completing this guide. The setup you have now is used by many leading tech companies to manage complex frontend projects, and you are well-prepared to scale your development efforts. Happy coding, bro!

This article, images or code examples may have been refined, modified, reviewed, or initially created using Generative AI with the help of LM Studio, Ollama and local models.