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
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
// 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"
}
}// 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"
}
}tsxruns TS directly in dev (fast, esbuild-based, no type-check). Runtsc --noEmitin 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
.jsextensions: With"module": "NodeNext"and"type": "module", relative imports must include.jseven in.tsfiles โ 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
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
- How would you structure a MERN app in TypeScript? โ Monorepo, shared package for types/schemas, feature-based modules, layered server.
- How do you share types between client and server? โ Workspace package (
@app/shared) exporting interfaces, DTO types andas constconstants; or generate types from an OpenAPI spec; or use tRPC. - Why separate
app.tsandserver.ts/index.ts? โ Testability and clean bootstrap/shutdown. - How do you handle environment variables safely? โ Read and check them once at boot, export a typed
envobject, fail fast with every error listed. - What's the difference between
tsx/ts-nodeandtsc? โ Dev runners transpile on the fly (often skip type-check);tsctype-checks and emits JS for production. - Controller vs service โ what goes where? โ Controller = HTTP; service = business logic, reusable from jobs/CLI/tests.
- 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 โ