Chapter 83 โ€” MERN + TypeScript Project Setup & Architecture

๐Ÿ“– Definition

A production MERN + TypeScript project is usually organised as a monorepo with three parts:

  • apps/server โ€” Node.js + Express (or NestJS) + Mongoose, compiled TypeScript.
  • apps/web โ€” React + TypeScript, built with Vite (or Next.js).
  • packages/shared โ€” TypeScript types and constants used by both sides, so the API contract is written once.

๐Ÿ” Why This Structure?

  • Single source of truth for request/response shapes โ€” no copy-pasted interfaces drifting apart.
  • Atomic changes โ€” a PR that changes an endpoint updates server, client and shared types together.
  • Layered server (routes โ†’ controllers โ†’ services โ†’ models) keeps HTTP concerns out of business logic, which makes it testable.

๐Ÿ’ป Folder Structure

text
mern-app/
โ”œโ”€โ”€ package.json                # npm/pnpm workspaces
โ”œโ”€โ”€ tsconfig.base.json          # shared compiler options
โ”œโ”€โ”€ packages/
โ”‚   โ””โ”€โ”€ shared/
โ”‚       โ”œโ”€โ”€ package.json        # "name": "@app/shared"
โ”‚       โ””โ”€โ”€ src/
โ”‚           โ”œโ”€โ”€ index.ts
โ”‚           โ”œโ”€โ”€ types/user.ts             # User, PublicUser, CreateUserDto
โ”‚           โ”œโ”€โ”€ types/api.ts              # ApiResponse<T>, Paginated<T>
โ”‚           โ””โ”€โ”€ constants.ts              # ROLES, ORDER_STATUS (as const)
โ””โ”€โ”€ apps/
    โ”œโ”€โ”€ server/
    โ”‚   โ”œโ”€โ”€ package.json
    โ”‚   โ”œโ”€โ”€ tsconfig.json
    โ”‚   โ””โ”€โ”€ src/
    โ”‚       โ”œโ”€โ”€ index.ts                  # bootstrap: connect DB, listen
    โ”‚       โ”œโ”€โ”€ app.ts                    # build Express app (no listen โ†’ testable)
    โ”‚       โ”œโ”€โ”€ config/env.ts             # validated env vars
    โ”‚       โ”œโ”€โ”€ db/connect.ts
    โ”‚       โ”œโ”€โ”€ modules/
    โ”‚       โ”‚   โ””โ”€โ”€ users/
    โ”‚       โ”‚       โ”œโ”€โ”€ user.model.ts
    โ”‚       โ”‚       โ”œโ”€โ”€ user.service.ts
    โ”‚       โ”‚       โ”œโ”€โ”€ user.controller.ts
    โ”‚       โ”‚       โ””โ”€โ”€ user.routes.ts
    โ”‚       โ”œโ”€โ”€ middleware/               # auth, validate, errorHandler
    โ”‚       โ”œโ”€โ”€ utils/                    # AppError, asyncHandler, logger
    โ”‚       โ””โ”€โ”€ types/express.d.ts        # Request augmentation
    โ””โ”€โ”€ web/
        โ”œโ”€โ”€ package.json
        โ”œโ”€โ”€ vite.config.ts
        โ””โ”€โ”€ src/
            โ”œโ”€โ”€ main.tsx
            โ”œโ”€โ”€ api/                      # typed API client
            โ”œโ”€โ”€ features/users/           # components + hooks per feature
            โ”œโ”€โ”€ hooks/
            โ”œโ”€โ”€ store/                    # Redux Toolkit (optional)
            โ””โ”€โ”€ routes/

Feature-based (modules/users/*) scales better than type-based (controllers/, models/, routes/) once you have more than a handful of entities โ€” everything about "users" lives in one folder.

๐Ÿ’ป Code Example โ€” Workspaces

jsonc
// package.json (root)
{
  "name": "mern-app",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "dev": "npm run dev --workspaces --if-present",
    "build": "npm run build -w @app/shared && npm run build -w server && npm run build -w web",
    "typecheck": "tsc -b"
  }
}
jsonc
// apps/server/package.json
{
  "name": "server",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@app/shared": "*",
    "express": "^5.1.0",
    "express-validator": "^7.2.0",
    "mongoose": "^8.0.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.6.0"
  }
}
  • tsx runs TS directly in dev (fast, esbuild-based, no type-check). Run tsc --noEmit in CI for type safety.
  • Alternatives: ts-node-dev, nodemon + ts-node, or Node 22.6+'s --experimental-strip-types (type stripping only โ€” no enums/namespaces).

๐Ÿ’ป Code Example โ€” Validated Environment Config

Never read process.env.X all over the code โ€” it's string | undefined and unchecked. Parse it once at startup and crash early if it's wrong.

// apps/server/src/config/env.ts
const NODE_ENVS = ["development", "test", "production"] as const;
type NodeEnv = (typeof NODE_ENVS)[number];

export interface Env {
  NODE_ENV: NodeEnv;
  PORT: number;
  MONGO_URI: string;
  JWT_ACCESS_SECRET: string;
  JWT_REFRESH_SECRET: string;
  CLIENT_ORIGIN: string;
}

const errors: string[] = [];

function required(name: string): string {
  const value = process.env[name];
  if (!value) errors.push(`${name} is required`);
  return value ?? "";
}

function secret(name: string): string {
  const value = required(name);
  if (value && value.length < 32) errors.push(`${name} must be at least 32 characters`);
  return value;
}

function isNodeEnv(v: string): v is NodeEnv {
  return (NODE_ENVS as readonly string[]).includes(v);
}

const rawNodeEnv = process.env.NODE_ENV ?? "development";
if (!isNodeEnv(rawNodeEnv)) errors.push(`NODE_ENV must be one of ${NODE_ENVS.join(", ")}`);

const port = Number(process.env.PORT ?? 4000);
if (!Number.isInteger(port) || port <= 0) errors.push("PORT must be a positive integer");

export const env: Env = {
  NODE_ENV: rawNodeEnv as NodeEnv,
  PORT: port,
  MONGO_URI: required("MONGO_URI"),
  JWT_ACCESS_SECRET: secret("JWT_ACCESS_SECRET"),
  JWT_REFRESH_SECRET: secret("JWT_REFRESH_SECRET"),
  CLIENT_ORIGIN: required("CLIENT_ORIGIN"),
};

if (errors.length) {
  console.error("โŒ Invalid environment variables:\n - " + errors.join("\n - "));
  process.exit(1);
}

The Env interface is the contract; the helpers collect all problems before exiting, so you fix them in one go instead of one crash at a time.

๐Ÿ’ป Code Example โ€” Separate app.ts from index.ts

// apps/server/src/app.ts
import express, { type Express } from "express";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import { env } from "./config/env.js";
import { userRouter } from "./modules/users/user.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { notFound, errorHandler } from "./middleware/error.js";

export function createApp(): Express {
  const app = express();

  app.use(helmet());
  app.use(cors({ origin: env.CLIENT_ORIGIN, credentials: true }));
  app.use(express.json({ limit: "1mb" }));
  app.use(cookieParser());

  app.get("/health", (_req, res) => { res.json({ status: "ok" }); });
  app.use("/api/auth", authRouter);
  app.use("/api/users", userRouter);

  app.use(notFound);
  app.use(errorHandler);   // must be last
  return app;
}
// apps/server/src/index.ts
import mongoose from "mongoose";
import { createApp } from "./app.js";
import { env } from "./config/env.js";

async function main() {
  await mongoose.connect(env.MONGO_URI);
  const server = createApp().listen(env.PORT, () =>
    console.log(`API on http://localhost:${env.PORT}`),
  );

  const shutdown = async (signal: string) => {
    console.log(`${signal} received, shutting down`);
    server.close(async () => {
      await mongoose.disconnect();
      process.exit(0);
    });
  };
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
  process.on("SIGINT", () => void shutdown("SIGINT"));
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Why split? Tests import createApp() and hit it with Supertest without opening a port or a real DB connection (see the Testing chapter in this section).

Note on .js extensions: With "module": "NodeNext" and "type": "module", relative imports must include .js even in .ts files โ€” TypeScript maps them to the compiled output.

๐Ÿ’ป Code Example โ€” React App with Vite + Proxy

// apps/web/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": { target: "http://localhost:4000", changeOrigin: true },
    },
  },
});

The proxy lets the browser call /api/... on the same origin in dev โ€” no CORS headaches, and cookies just work.

๐Ÿ’ป Code Example โ€” The Layered Request Flow

text
HTTP request
   โ”‚
   โ–ผ
Router  โ”€โ”€โ–บ middleware (auth, validate(schema))
   โ”‚
   โ–ผ
Controller   โ€” reads req, calls service, shapes response. No DB code.
   โ”‚
   โ–ผ
Service      โ€” business rules, transactions. No req/res.
   โ”‚
   โ–ผ
Model (Mongoose) โ€” persistence only.

๐ŸŒ Real-World Impact

  • New engineers find code by feature, not by guessing which of 5 folders it's in.
  • Env validation turns "works on my machine" production outages into an immediate, readable crash at boot.
  • createApp() makes integration tests fast and deterministic.

๐ŸŽฏ Likely Interview Questions

  1. How would you structure a MERN app in TypeScript? โ€” Monorepo, shared package for types/schemas, feature-based modules, layered server.
  2. How do you share types between client and server? โ€” Workspace package (@app/shared) exporting interfaces, DTO types and as const constants; or generate types from an OpenAPI spec; or use tRPC.
  3. Why separate app.ts and server.ts/index.ts? โ€” Testability and clean bootstrap/shutdown.
  4. How do you handle environment variables safely? โ€” Read and check them once at boot, export a typed env object, fail fast with every error listed.
  5. What's the difference between tsx/ts-node and tsc? โ€” Dev runners transpile on the fly (often skip type-check); tsc type-checks and emits JS for production.
  6. Controller vs service โ€” what goes where? โ€” Controller = HTTP; service = business logic, reusable from jobs/CLI/tests.
  7. How do you shut down a Node server gracefully? โ€” Handle SIGTERM, stop accepting connections, finish in-flight requests, close DB, exit.

โ† TypeScript Essentials | Index | Next: Typed Express Server โ†’