Skip to main content

Command Palette

Search for a command to run...

Setup : Monorepo-turborepo-tRPC

Updated
8 min readView as Markdown

Multi-repo VS Monorepo

  • Multirepo - react-frontend, express backend, postgress-db, redis server

  • Monorepo - sigle repo > one repo can contain frontent, backend, shared utils and types etc example project

  • apps -- web (fully deployed app) -- api // fully deployed backend

  • packages -- shared // common utils, db connection, schema

Benefits => code sharing, atomic commits, better team collab, unified cicd & tooling

PNPM => Performant NPM ,like npm,yarn or bun // popular in monorepo

  • Fast

  • Disk space efficient

  • insted of duplicate is store in central place and each time take refrence symlinks

PNPM vs NPM

pnpm-workspaces > depened on each other, import each other, share efficiently, install and run packages together

Project setup

npm i pnpm
mkdir monorepo && cd monorepo
pnpm init // update with required things

package.json

{
  "name": "monorepo",
  "private": true,
  "types": "module",
  "packageManager": "pnpm@10.33.4"
}

Folder setup

mkdir apps packages apps/api apps/web packages/utils

pnpm-workspace.yaml

the line that makes it a workspace. Any folder under apps/ or packages/ is now a linkable package:

packages:
  - "apps/*"
  - "packages/*"
allowBuilds:
  esbuild: true

Shared package setup

packages/utils

pnpm init
pnpm add -D typescript
pnpm add zod
tsc --init

packages/utils/package.json

{
  "name": "@monorepo/utils",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "dev": "tsc --watch",
    "build": "tsc"
  },
  "exports": {
    ".": "./src/index.ts"
  },
  "packageManager": "pnpm@10.33.4",
  "devDependencies": {
    "typescript": "^6.0.3"
  },
  "dependencies": {
    "zod": "^4.4.3"
  }
}

packages/utils/tsconfig.json

{
    // Visit https://aka.ms/tsconfig to read more about this file
    "compilerOptions": {
        // File Layout
        "rootDir": "./src", // ts
        "outDir": "./dist", // js

        // Environment Settings
        // See also https://aka.ms/tsconfig/module
        "module": "nodenext",
        "target": "esnext",
        "types": [],
        // For nodejs:
        // "lib": ["esnext"],
        // "types": ["node"],
        // and npm install -D @types/node

        // Other Outputs
        "sourceMap": true,
        "declaration": true,
        "declarationMap": true,

        // Stricter Typechecking Options
        "noUncheckedIndexedAccess": true,
        "exactOptionalPropertyTypes": true,

        // Style Options
        // "noImplicitReturns": true,
        // "noImplicitOverride": true,
        // "noUnusedLocals": true,
        // "noUnusedParameters": true,
        // "noFallthroughCasesInSwitch": true,
        // "noPropertyAccessFromIndexSignature": true,

        // Recommended Options
        "strict": true,
        "jsx": "react-jsx",
        "verbatimModuleSyntax": true,
        "isolatedModules": true,
        "noUncheckedSideEffectImports": true,
        "moduleDetection": "force",
        "skipLibCheck": true
    }
}

packages/utils/src/index.ts

Create Zod schema // for frontend and backend validation

import { z } from 'zod';
export const createUserSchema = z.object({
    name: z.string().min(3, "Name must be at least 3 characters long"),
    email: z.email("Invalid email address"),
    password: z.string().min(6, "Password must be at least 6 characters long")
})

export type CreateUserSchema = z.infer<typeof createUserSchema>; // zod will create schema like this

packages/utils/package.json export the index file

"exports":{
    ".":"./src/index.ts"
}

packages/utils pnpm build

Backend setup

apps/api

Backend setup for API

pnpm init
pnpm add express @types/express -D typescript tsx @types/node
tsc --init

update .tsconfig.json

  • rootDir

  • outDir

  • lib

  • types

apps/api/package.json update with

{
  "name": "@monorepo/api",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc"
  },
  "packageManager": "pnpm@10.33.4",
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^26.0.0",
    "express": "^5.2.1",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3"
  }
}

apps/api/src/index.ts

Basic get request

import express  from "express";
const app = express();
const PORT = 5000;
app.use(express.json());

app.get("/", (req, res) => {
    return res.json({ message: "Hello from API" });
})

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

Now import ZOD schema to the backend api

apps/api/package.json name:"@projectName/api" // "@repo/api" dependcies:{ "@projectName/utils": "workspace:*" // indicate get from local not version } apps/api pnpm i // insalled and symlinks created

apps/api/src/index.ts Import Zod from symlinks or shared folder

import { createUserSchema } from "@monorepo/utils";
app.post("/users", (req, res) => {
    const result = createUserSchema.safeParse(req.body);
    if(!result.success) {
        const message = result.error.issues.map((issue) => issue.message).join(", ")
        return res.status(400).json({success:false, message:message });
    }
    console.log(result.data);
    return res.json({success:true, message: "User created" });
});

Frontend setup

apps/web

pnpm create next-app .
remove pnpm-workspace.yaml its confilict remove public folder

package/web/package.json update the dependencies "name": "@monorepo/web",// "@repo/utils" pnpm i

package/web/app/page.tsx

"use client";
import { createUserSchema } from "@monorepo/utils";
import axios from "axios";
import { useState } from "react";
import type { SubmitEvent } from "react";

export default function Home() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    password: "",
  });
  const [errors, setErrors] = useState("");
  const [success, setSuccess] = useState(false);
  const handleSubmt = async (e: SubmitEvent<HTMLFormElement>) => {
    setSuccess(false);
    setErrors("");
    e.preventDefault();
    const result = createUserSchema.safeParse(formData);
    if (!result.success) {
      const message = result.error.issues
        .map((issue) => issue.message)
        .join(", ");
      setErrors(message);
      return alert(message);
    }
    console.log(result.data);
    try {
      const res = await axios.post("http://localhost:5000/users", result.data);
      console.log(res);
      setSuccess(true);
    } catch (error) {
      console.log(error);
    }
  };
  return (
    <form onSubmit={handleSubmt} noValidate>
      {errors && errors.split(", ").map((error, i) => <p key={i}>{error}</p>)}
      {success && <p>Success</p>}
      <input
        type="text"
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <input
        type="email"
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
      />
      <input
        type="password"
        onChange={(e) => setFormData({ ...formData, password: e.target.value })}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

package/web pnpm i axios and update package/web/app/page.tsx post requiest on submit ""

const res = await axios.post("http://localhost:5000/users", result.data)

check for cors error and make form submit

all in one setup for all

for all run and build from one place

package.json

"scripts": {
    "dev": "pnpm -r --parallel dev",
    "build": "pnpm -r build"
  }

TurboRepo setup

package is depandend on each other (dependcy graph). Build in Right order

Turbo repo // turborepo.dev

  • Build order manage

  • caching

  • task orchestration

  • parrlel excuation

Add turbo in project

pnpm add -D turbo -w // -w is for do it now

turbo.json

{
    "$schema":"https://turbo.build/schema.json",
    "ui":"tui", // terminal ui to looks good the console the logs for each server
    "tasks":{
        "build": {
            "dependsOn": ["^build"], // check for dependent other build (resolve dependecy graph)
            "outputs": ["dist/**", "next/**", "!dist/**/node_modules/**", "!next/**/node_modules/**", "!.next/cache/**"] //caching
        },
        "dev":{
            "cache": false, // no need to cache
            "persistent": true // long running
        },
        "prettier":{
            "cache": false,
            "persistent": true
        },
        "lint":{
            "cache": false,
            "persistent": true
        }
    }
}

package.json

  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build"
  },

at Root folder pnpm dev pnpm build

tRPC (Remote procedure calls)

clinet will call the remote server RPC concept call without "stings" call the direct methods impelentation (gRPC and tRPC)

Packages or share utils

packages/trpc pnpm init pnpm add -D typescript pnpm add @trpc/server tsc --init packages/trpc/package.json

"name":"@monorepo/trpc"
private:true
export:trpc
dependies:{
    "@monorepo/utils": "workspace:*",
}
scripts:{
    "dev":"tsc --watch",
    "build":"tsc"
}

packages/trpc/src/trpc.ts

import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router; // rotuer > function declare
export const procedure = t.procedure; // functions

packages/trpc/src/router.ts

import { procedure, router } from "./trpc.js";
import { createUserSchema } from "@peer-class/utils";
export const appRouter = router({
    health: procedure.query(() => {
        return {
            message: "healthy"
        }
    }),
    register: procedure
        .input(createUserSchema)
        .mutation(({ input }) => {
            // TODO: persist user to DB
            console.log("register", input);
            return {
                message: "User Registered Successfully"
            }
        }),
});

export type AppRouter = typeof appRouter;

packages/trpc/src/index.ts

export { appRouter } from './router.js';
export type { AppRouter } from './router.js';

pnpm i

Backend

apps/api/ pnpm add @trpc/server

add trpc package at package.json pnpm i

apps/api/src/index.ts

import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter } from "@peer-class/trpc";
// remove app.get (no need of rest api)
app.use(
    "/trpc",
    createExpressMiddleware({
        router: appRouter
    })
);

Frontend

apps/web pnpm add @trpc/client @tanstack/react-query @trpc/react-query app/web/trpc/trpc.ts

import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@peer-class/trpc";
export const trpc = createTRPCReact<AppRouter>();

app/web/trpc/Provider.tsx

'use client';
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "./trpc";

export function Provider({ children }: { children: React.ReactNode }) {
    // React Query client — manages server state caching, background refetching, etc.
    const [queryClient] = useState(() => new QueryClient());

    // tRPC client — batches multiple procedure calls into a single HTTP request to /trpc
    const [trpcClient] = useState(() =>
        trpc.createClient({
            links: [
                httpBatchLink({
                    url: "http://localhost:5000/trpc",
                }),
            ],
        })
    );

    // trpc.Provider bridges tRPC with React Query so tRPC hooks use the shared queryClient
    return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
            <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        </trpc.Provider>
    );
}

/*
 * What this file does:
 * ---------------------
 * Sets up the tRPC + React Query context for the entire Next.js app.
 *
 * - QueryClient      → React Query's cache store. Holds all server-state (queries/mutations).
 * - trpcClient       → HTTP client that sends batched requests to the Express backend at
 *                      http://localhost:5000/trpc using tRPC's httpBatchLink.
 * - trpc.Provider    → Makes tRPC hooks (trpc.*.useQuery / useMutation) available in the tree.
 * - QueryClientProvider → Makes React Query hooks (useQuery, useMutation) available in the tree.
 *
 * Both providers share the same queryClient so tRPC and React Query stay in sync.
 * Wrap the root layout (layout.tsx) with this Provider to enable tRPC across the whole app.
 */

apps/web/app/layout.tsx

import { Provider } from "../trpc/Provider";
<Provider>{children}</Provider> // get everywhere for query and mutations

apps/web/app/health/page.tsx

'use client';
import { trpc } from "@/trpc/trpc"
export default function Health(){
    const health = trpc.health.useQuery();
    console.log(health.data);
    return (
        <div>
            <h1>Healthy</h1>
        </div>
    )
}

Final commad at root to run all
pnpm dev
pnpm build