As a developer or intern, you may have encountered the challenge of managing multiple applications or packages within a single project. Whether it’s working on a frontend app while simultaneously building a backend API or sharing UI components and utility functions across different parts of your project, things can quickly become unmanageable.
This is where monorepos come in. By organizing your code into a single repository that contains multiple apps and shared packages, you can streamline your development process and improve collaboration. In this guide, we’ll walk you through setting up a monorepo using Turbopack, Biome, Next.js 15, Express.js, Tailwind CSS, and ShadCN. We’ll also use pnpm as our package manager to optimize dependency management.
By the end of this tutorial, you’ll have a fully functioning monorepo with two apps (Next.js and Express.js) and three shared packages (UI components, TypeScript types, and utility functions). Let’s dive in!
P.S. You’ll find the repository link at the end of the post. If you’ve never set up a monorepo before, I highly recommend reading through the entire tutorial rather than just forking the repository.
Prerequisites
Before starting this tutorial, ensure you have the following installed:
- Visual Studio Code (or a similar code editor that supports Biome)
- Biome extension for VSC
- NodeJS (recommended to install via NVM for easy version management)
What is a Monorepo?
Before we start building, let’s clarify what a monorepo is and why it’s useful.
Definition
A monorepo is a single repository that holds the code for multiple projects or packages. Instead of having separate repositories for each app or shared library, everything lives in one place.
Benefits of Using a Monorepo
- Code Sharing: Easily share code between different apps (e.g., UI components or utility functions).
- Consistency: Maintain consistent dependencies and configurations across all projects.
- Simplified Collaboration: Developers working on different parts of the project can collaborate more easily since everything is in one place.
- Atomic Changes: Make changes across multiple apps or packages in one commit.
- Centralized CI/CD: Manage continuous integration and deployment pipelines from one place.
In this guide, we’ll create a monorepo (turborepo) that contains:
- A Next.js app (frontend).
- An Express.js app (backend).
- Packages for UI components (using Tailwind CSS + ShadCN), shared types, and utility functions.
Why Use pnpm and Turbopack?
To make our monorepo efficient and scalable, we’ll use two key tools: pnpm for package management and Turbopack/Turborepo for fast builds.
pnpm
pnpm is an alternative to npm and Yarn that offers several advantages:
- Faster Installs: pnpm installs dependencies faster by using hard links instead of copying files.
- Disk Space Efficiency: It saves disk space by avoiding duplicate dependencies.
- Workspaces Support: pnpm supports workspaces natively, making it ideal for monorepos where you have multiple projects sharing dependencies.
Turbopack
Turbopack is the new bundler introduced by Vercel for Next.js. It’s designed to be much faster than Webpack, especially during development:
- Faster Hot Module Replacement (HMR): Turbopack speeds up development by reloading only the necessary modules when you make changes.
- Optimized Production Builds: Turbopack optimizes your production builds to be smaller and faster.
- Seamless Integration with Next.js 15: Turbopack works out of the box with Next.js new app directory structure.
With these tools in hand, let’s move on to setting up our project structure.
Project Structure Overview
Here’s what our final project structure will look like:
monorepo/
├── .vscode/
│ ├── extensions.json
│ └── settings.json
├── apps/
│ ├── web/ # Next.js app
│ └── server/ # Express.js app
├── packages/
│ ├── ui/ # Shared UI components (using Tailwind CSS + ShadCN)
│ ├── types/ # Shared TypeScript types
│ ├── tsconfig/ # Typescript configuration
│ └── utils/ # Shared utility functions
├── .gitignore # Untrack files from git
├── biome.json # Biome configuration
├── package.json # Project configuration
├── turbo.json # Turbopack configuration
└── pnpm-workspace.yaml # pnpm workspace configuration
We’ll organize our project into two main directories:
apps/
: This will contain our two main applications—web
(Next.js) andserver
(Express.js).packages/
: This will contain shared code that both apps can use—ui
for shared UI components,types
for TypeScript types,utils
for shared utility functions andtsconfig
for typescript configuration files.
Now that we have an overview of the structure, let’s start setting up the monorepo.
Setting Up the Monorepo
Step 1: Initialize the Monorepo with pnpm Workspaces
First, we need to install pnpm globally if you don’t already have it:
npm install -g pnpm
Next, create your main project directory:
mkdir monorepo && cd monorepo
Initialize a new workspace:
pnpm init
This command creates a package.json
file at the root of your project. Now we need to tell pnpm which directories should be part of the workspace by creating a pnpm-workspace.yaml
file at the root:
packages:
- 'apps/*'
- 'packages/*'
This configuration tells pnpm that any folder inside apps/
or packages/
should be treated as part of the workspace.
Step 2: Configure Turbopack
Next, we’ll configure Turbopack by creating a turbo.json
file at the root of your project:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"topo": {
"dependsOn": [
"^topo"
]
},
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
]
},
"lint": {
"dependsOn": [
"^topo"
]
},
"format": {
"dependsOn": [
"^topo"
]
},
"lint:fix": {
"dependsOn": [
"^topo"
]
},
"format:fix": {
"dependsOn": [
"^topo"
]
},
"check-types": {},
"dev": {
"cache": false,
"persistent": true
},
"add-shadcn-component": {
"dependsOn": [
"^topo"
]
},
"clean": {
"cache": false
}
}
}
This configuration defines how Turbopack should handle builds across your workspace.
Notice the
add-shadcn-component
command, this is a custom command that will be used in our UI package to easily add new components from ShadCN directly from root.
Step 3: Global configs
Following, we’ll update our root package.json
to add scripts and dependencies.
{
"name": "monorepo",
"private": true,
"scripts": {
"changeset": "changeset",
"publish:packages": "changeset publish",
"version:packages": "turbo build && changeset version",
"add-shadcn-component": "turbo run add-shadcn-component -- --",
"build": "turbo build",
"dev": "turbo dev",
"format": "turbo format --continue --",
"format:fix": "turbo format --continue -- --write",
"lint": "turbo lint --continue --",
"lint:fix": "turbo lint --continue -- --apply",
"clean": "turbo clean"
},
"dependencies": {
"@changesets/changelog-github": "^0.5.0",
"@changesets/cli": "^2.27.1",
"turbo": "^2.1.3"
},
"devDependencies": {
"@biomejs/biome": "^1.7.2",
"typescript": "^5",
"postcss": "^8.4.27"
},
"packageManager": "[email protected]"
}
For Biome config, we will create a file named biome.json
:
{
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
"files": {
"ignoreUnknown": true,
"ignore": [
"node_modules/*",
"*.config.*",
"*.json",
"tsconfig.json",
".turbo",
"**/dist",
"**/out",
".next"
]
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off",
"noUselessFragments": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
},
"style": {
"noParameterAssign": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"lineEnding": "lf",
"lineWidth": 120
}
}
A very important file is the .gitignore
, this is the file where we will tell Git which files we don’t want to be tracked.
# dependencies
/node_modules
/.pnp
.pnp.js
node_modules
packages/*/node_modules
apps/*/node_modules
.next
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
.pnpm-debug.log*
# other lockfiles that's not pnpm-lock.yaml
package-lock.json
yarn.lock
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# intellij
.idea
dist/**
/dist
packages/*/dist
.turbo
/test-results/
/playwright-report/
/playwright/.cache/
This configuration defines our project defaults. Now that we’ve set up our workspace configuration files, let’s move on to creating our apps.
Step 3: .vscode folder
The .vscode
folder in a project directory stores configuration settings specifically for Visual Studio Code. These settings allow you to personalize and optimize VS Code for your project or workspace needs. Here are the two main types of settings:
- User Settings: Apply globally across all VS Code instances on your system. They are perfect for settings you want to keep consistent, like font size or theme.
- Workspace Settings: Apply only to the current project. This is useful for project-specific configurations, like excluding certain folders (e.g.,
node_modules
) from your file explorer.
VS Code uses JSON files to store these settings, enabling easy customization and sharing through version control. For easy management, you can modify settings directly in the JSON file or use the Settings editor, which provides a convenient graphical interface.
For our project we will create two files that store these configs. First, create a folder at the root with the name of .vscode
. Then, create extensions.json
:
{
"recommendations": [
"yoavbls.pretty-ts-errors",
"bradlc.vscode-tailwindcss",
"biomejs.biome"
]
}
The last config we need is the global settings, so create a file named settings.json
:
{
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit",
},
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.autoImportFileExcludePatterns": [
"next/router.d.ts",
"next/dist/client/router.d.ts"
],
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}
Creating the first package (tsconfig)
To create the typescript config that will be used in all our monorepo and the individual configs for our web and server, we will create our tsconfig package.
mkdir packages && cd packages && mkdir tsconfig && cd tsconfig
We will have 6 config files for Typescript:
- base:
base.json
- web:
next.json
- server:
express.json
- ui:
ui.json
- utils: utils.json
- types: types.json
First we will create our package.json
:
{
"name": "@monorepo/tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}
Then we will create our base.json
config file (you can find every tsconfig setting here):
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"alwaysStrict": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"noEmit": true,
"declaration": true,
"declarationMap": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"downlevelIteration": true,
"allowJs": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules",
"src/tests"
]
}
Now we will create the next.json
config:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"paths": {
"@/*": [
"./*"
]
},
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
}
}
Then we need to create the express.json
config file:
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "ExpressJS Server",
"extends": "./base.json",
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "Node10"
}
},
"compilerOptions": {
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "ESNext"
}
}
Create a types.json
config file for our shared types package:
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Shared Types",
"extends": "./base.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
},
}
Also, we need to add the last config file ui.json
, this file will be used in our shared UI package.
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Shared UI",
"extends": "./base.json",
"compilerOptions": {
"paths": {
"@/*": [
"./*"
]
},
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
}
}
Finally, create the utils.json config:
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Shared UI",
"extends": "./base.json",
"compilerOptions": {
"paths": {
"@/*": [
"./*"
]
},
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
}
}
This is how your tsconfig package folder will look like:
Congrats, we’ve just finished configuring our typescript. Now let’s heads up to the exciting part: create our apps!
Creating the Apps
Step 1: Setup Next.js 15 App (web
)
Create and navigate to the apps/
directory:
cd ../.. && mkdir apps && cd apps
Create a new Next.js app using pnpm:
pnpm create next-app@latest web --ts --app --turbopack --no-eslint --tailwind --src-dir --skip-install --import-alias @/*
This command will create a new Next.js app in the web/
folder with TypeScript enabled, Turbopack set as the default bundler and Tailwind CSS.
To integrate our tsconfig package into the web app, we need to update the default package.json
:
...,
"dependencies": {
"@monorepo/types": "workspace:*",
"@monorepo/ui": "workspace:*",
"@monorepo/utils": "workspace:*",
"react": "19.0.0-rc-02c0e824-20241028",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"next": "15.0.2"
},
"devDependencies": {
"@monorepo/tsconfig": "workspace:*",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"@biomejs/biome": "^1.7.2"
}
...,
Npw, update the default tsconfig.json
:
{
"extends": "@monorepo/tsconfig/next.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"tailwind.config.ts"
],
"exclude": [
"node_modules"
]
}
Add biome.json
so we can activate it on the folder:
{
"extends": ["../../biome.json"]
}
Your Next.js app is now set up! Let’s move on to setting up our backend app using Express.js.
Step 2: Setup Express App (server
)
Navigate back to the apps/
directory and create an Express app:
cd .. && mkdir server && cd server && pnpm init
Update your server’s package.json
to add Express, it’s types, cors, morgan and ts-node-dev:
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"keywords": [],
"author": "",
"license": "ISC",
"scripts": {
"dev": "ts-node-dev --transpile-only src/server.ts"
},
"dependencies": {
"@monorepo/types": "workspace:*",
"express": "^4.21.1",
"ts-node-dev": "^2.0.0",
"cors": "2.8.5",
"morgan": "^1.10.0"
},
"devDependencies": {
"@monorepo/tsconfig": "workspace:*",
"@types/express": "^5.0.0",
"@types/morgan": "^1.9.9",
"@types/cors": "2.8.17"
}
}
Add tsconfig.json
to the server:
{
"extends": "@monorepo/tsconfig/express.json",
"include": [
"src"
],
}
Create a basic Express server in src/server.ts
:
import cors from "cors";
import express from "express";
import morgan from "morgan";
const app = express();
app.use(morgan("tiny"));
app.use(express.json({ limit: "100mb" }));
app.use(
cors({
credentials: true,
origin: ["http://localhost:3000"],
}),
);
const port = process.env.PORT || 3001;
app.get("/", (_, res) => {
res.send("Hello from Express!");
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
You now have both your frontend (Next.js) and backend (Express) apps set up! Let’s move on to creating shared packages that both apps can use.
In this section, we’ll create three shared packages: one for UI components (ui
), one for TypeScript types (types
), and one for utility functions (utils
). These packages will live inside the packages/
directory.
Step 1: Create utils
Package
The first package we’ll create is for utility functions (utils
). To set it up:
Create the folder inside packages/
, initialize it:
cd ../.. && mkdir packages && cd packages && mkdir utils && cd utils && pnpm init && mkdir src && touch src/styles.ts
Update package.json
to add scripts and exports:
{
"name": "@monorepo/utils",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"check-types": "tsc --noEmit",
"build": "tsup",
"lint": "biome lint ./src",
"format": "biome format ./src "
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@monorepo/tsconfig": "workspace:*"
},
"exports": {
".": "./src",
"./styles": "./src/styles.ts"
}
}
Add biome.json
:
{
"extends": [
"../../biome.json"
]
}
Add tsconfig.json
:
{
"extends": "@monorepo/tsconfig/utils.json",
"include": [
"**/*.ts",
],
"exclude": [
"node_modules"
],
}
The first (and unique) util function we will create is cn
, a utility function to merge tailwind classes conditionally and it’s heavily used in ShadCN components. We need to add the following deps:
pnpm add clsx tailwind-merge
Add cn common utility function inside src/style.ts
:
import clsx, { type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Step 2: Create ui
Package (Tailwind CSS + ShadCN)
Navigate back to the packages/
directory:
cd .. && mkdir ui && cd ui && pnpm init
Install React along with Tailwind CSS (dev deps) and ShadCN (we will be using new york style):
pnpm add -D @types/react @types/react-dom autoprefixer postcss react tailwindcss typescript
pnpm add shadcn @types/react tailwindcss-animate class-variance-authority clsx tailwind-merge @radix-ui/react-icons
npx tailwindcss init
Set up Tailwind CSS by following similar steps as we did in the Next.js app—initialize Tailwind CSS (npx tailwindcss init
) and configure it in tailwind.config.ts
:
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: '`var(--radius)`',
md: '`calc(var(--radius) - 2px)`',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans]
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [tailwindcssAnimate],
} satisfies Config;
export default config;
We also need to configure postcss.config.mjs
for Tailwind CSS:
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
Since we are going to use Biome on this package also, add biome.json
:
{
"extends": [
"../../biome.json"
]
}
Update the package.json
to add the tsconfig
, utils
packages and custom scripts:
{
"name": "@monorepo/ui",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"check-types": "tsc --noEmit",
"add-shadcn-component": "pnpm dlx shadcn@latest add",
"build": "tsup",
"lint": "biome lint ./src",
"format": "biome format ./src "
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@monorepo/tsconfig": "workspace:*",
"@types/react": "^18.3.12",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"react": "19.0.0-rc-02c0e824-20241028",
"tailwindcss": "^3.4.1"
},
"dependencies": {
"@monorepo/utils": "workspace:^",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-select": "^2.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"shadcn": "^2.1.3",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./tailwind.config": "./tailwind.config.ts",
"./components/*": "./src/*.tsx"
}
}
Create a tsconfig.json
file:
{
"extends": "@monorepo/tsconfig/ui.json",
"include": [
"**/*.ts",
"**/*.tsx",
"tailwind.config.ts",
],
"exclude": [
"node_modules"
],
}
Create a style file at src/styles/globals.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
ShadCN requires you to create a components.json
(enables CLI usage):
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "src/",
"ui": "src/",
"utils": "@monorepo/utils/styles"
}
}
Now you can start adding reusable UI components in this package! For example, to import the ShadCN Button component just run the following command at the root workspace:
pnpm add-shadcn-component card
You can find every ShadCN component here and others created on top of it here. Now we’re ready to set up our shared types package and integrate everything we’ve setup!
Step 3: Create types
package
The types
package will contain shared TypeScript types that both apps can use. To create it:
Navigate back to packages/
, create the folder, and initialize it:
cd .. && mkdir types && cd types && pnpm init
Create the biome.json file:
{
"extends": [
"../../biome.json"
]
}
Create the tsconfig.json
file:
{
"extends": "@monorepo/tsconfig/types.json",
"include": [
"**/*.ts",
],
"exclude": [
"node_modules"
],
}
The first types we will create will be a simple api client, so we can share type between server
and web
. Create src/
folder and inside it create api/
folder. Then create simple-api-client.ts
:
export interface GetTestResponse {
message: string;
}
export type GetTest = () => Promise<GetTestResponse>;
export interface SimpleApiClient {
getTest: GetTest;
}
Update package.json
to add exports, scripts and devDependencies:
{
"name": "@monorepo/types",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "tsc",
"lint": "biome lint ./src",
"check-types": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@monorepo/tsconfig": "workspace:*"
},
"exports": {
".": "./src/index.ts"
}
}
Now, create a index.ts at src/api folder and export everything from simple-api-client.ts (you will replicate it with other files to have a single source of import):
export * from "./simple-api-client";
Finally, create a index.ts at src folder and export everything from api:
export * from "./api";
Our shared types package is all set up! Your repository should look like this:
Now, let’s move on to the final part of our tutorial: integrating everything and running the development environment.
Running Your Monorepo Locally
Now that everything is set up, let’s run both apps locally!
Step 1: Install All Dependencies
To install all dependencies across your workspace at once (remember to change dir back to root):
pnpm install
This command installs all necessary dependencies for both apps (`web`, `server`) as well as all shared packages (`ui`, `types`, etc.).
### Step 2: Run Both Apps Concurrently
pnpm turbo run dev
This command installs all necessary dependencies for both apps (web
, server
) as well as all shared packages (ui
, types
, etc.).
This command starts both your frontend (Next.js) on port 3000 and backend (Express) on port 3001 simultaneously!
Web and Server integration
To create a simple integration between our apps and packages, we will develop a component that will fetch data from the server using the shared types we created earlier in this tutorial. But, before we do that, let’s update our Tailwind CSS files and global style to use the ones we defined at the UI
package. Replace the content of tailwind.config.ts with the following:
export * from "@monorepo/ui/tailwind.config";
Now replace postcss.config.mjs
content with:
export { default } from "@monorepo/ui/postcss.config";
At our root layout (src/app/layout.tsx
) update the globals.css import to use the one we created at the UI package:
import "@monorepo/ui/globals.css";
import "./style.css";
import type { Metadata } from "next";
import localFont from "next/font/local";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased dark`}>{children}</body>
</html>
);
}
This is being done so we can control our app UI styles and configs from the shared UI package, so that if we create another web app (e.g. admin dashboard) we have consistent stylings!
To start developing our application, we will organize our web folder structure following the following rules: components that will be used only in a page should be inside a folder called components
at the same level of the page folder in app
directory. App shared components should be in src/components
folder.
So, let’s create a components folder inside app
directory src/app/components
(we will create a component that will be used only at the first page) and create a file named get-test.tsx
with the following content:
"use client";
import type { GetTestResponse } from "@monorepo/types";
import { Card, CardContent, CardHeader } from "@monorepo/ui/components/card";
import { cn } from "@monorepo/utils/styles";
import { useEffect, useState } from "react";
const GetTest = () => {
const [test, setTest] = useState<string>("");
useEffect(() => {
const fetchTest = async () => {
const response = await fetch("http://localhost:3001/test");
const data: GetTestResponse = await response.json();
setTimeout(() => {
setTest(data.message);
}, 3000);
};
fetchTest();
}, []);
return (
<div>
<Card>
<CardHeader>
<h1 className={cn("text-xl text-yellow-500", test !== "" && "text-green-500")}>Get Test</h1>
</CardHeader>
<CardContent>
<p>{test}</p>
</CardContent>
</Card>
</div>
);
};
export default GetTest;
Take a look at the file we have a simple fetch to our server using the typed response we defined at the shared types, this allow us to work with the responses easily. The server route will be defined soon. First, let’s finish the web
part by importing the component at our page.tsx:
import GetTest from "./components/get-test";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<GetTest />
</div>
);
}
With web all set up, go to the server
app and update server.ts
with the route we will be using. Add the following route before app.listen
:
app.get("/test", (_, res) => {
const testJson: GetTestResponse = {
message: "Hello from Express API!",
};
res.json(testJson);
});
Finally, everything is done and you can run the entire app with the following command (remember to change directory back to root):
pnpm run dev
You will be able to view the following component rendered on the page.
As soon as the component is rendered we fetch the server inside the useEffect and set the state to render (the setTimeout is not necessary, it’s there just for the sake of visualizing state changing), then you will view the following.
Conclusion
Congratulations! You’ve successfully set up a scalable monorepo with two applications—a frontend built with Next.js, styled using Tailwind CSS, enhanced by reusable components from ShadCN, plus an Express backend—all managed efficiently using pnpm workspaces alongside blazing-fast builds powered by Turbopack and almost instantly liniting with Biome!
Here’s what you accomplished today:
- Created two separate applications within one repository.
- Set up four three packages containing reusable code and one for typescript configuration.
- Linked those packages between applications using efficient dependency management practices via pnpm workspaces.
We’ve reached the end of the tutorial, now you’re free to use your imagination to create anything you want with the best monorepo architecture out there. If you have any doubts or have a suggestion to improve this tutorial, feel free to contact me at any social.
Visit the repository and leave a star 👉 github.com/ivesfurtado/next-express-biome-turborepo
Leave a Reply