Part II - Creating the Apps
Step 1: Setup Next.js 15 App (web)
Create and navigate to the apps/ directory:
cd ../.. && mkdir apps && cd appsCreate 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"
}
...,pnpm, 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 initUpdate 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.
Creating Shared Packages
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-mergeAdd 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 initInstall 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-iconsnpx tailwindcss initSet 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 link here):
{
"$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 cardYou 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 initCreate 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:
IMAGE Here
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!
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 devThis 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 devYou will be able to view the following component rendered on the page.
IMAGE Here
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.
IMAGE HERE
Conclusion
You’ve successfully set up a scalable monorepo. Your progress today marks a significant milestone in mastering modern development architecture.
Here is a summary of your accomplishments:
- Integrated Multi-App Architecture: Successfully deployed two distinct applications within a unified repository.
- Modular Package Design: Established dedicated packages for reusable code and standardized TypeScript configurations.
- Optimized Dependency Linking: Leveraged pnpm workspaces to seamlessly link internal packages across applications, ensuring efficient dependency management.
With this foundational monorepo architecture in place, you now have a professional-grade environment ready for scalable development. You are well-positioned to extend this framework to suit your project’s unique requirements.