Better-TS-Stack
Modules

Backend Module

Express and NestJS backends with TypeScript, routing, and middleware

Overview

The Backend module generates an Express 5 or a NestJS 12 server with TypeScript. Every Backend API project starts from one of them. The sections below describe Express first.

What You Get

src/
|-- index.ts          # Entry point - configures middleware, mounts routes, starts server
|-- env.ts            # Validates environment variables at startup
\-- routes/
    \-- health.ts     # GET /health endpoint

The database and auth modules add more files to src/.


Generated Files

src/index.ts

The entry point configures and starts the Express app:

import cors from "cors";
import "dotenv/config";
import express, { Express, NextFunction, Request, Response } from "express";
import helmet from "helmet";
import morgan from "morgan";

import { env } from "./env";
import healthRouter from "./routes/health";

const app: Express = express();
const port = env.PORT;

app.use(helmet());
app.use(cors({ origin: env.CORS_ORIGIN }));
app.use(morgan("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use("/health", healthRouter);

// 404 handler
app.use((_req: Request, res: Response) => {
  res.status(404).json({ error: "Not Found" });
});

// Error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: "Internal Server Error" });
});

const server = app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

// Finish open requests and close the database before exiting
const shutdown = () => server.close(() => process.exit(0));
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

export default app;

With the auth module, src/index.ts also mounts the auth router at /auth. With Mongoose, the server connects to MongoDB before it starts listening.

On SIGTERM (what Docker and most hosts send to stop a container) or SIGINT (Ctrl+C), the server stops accepting connections, lets open requests finish, closes the database connection, and exits.

src/env.ts

Reads .env and checks every variable the project uses with Zod before the server starts. A missing or malformed value stops the server with a message that names the variable, instead of failing on the first request that needs it. Import env instead of reading process.env:

import { env } from "./env";

console.log(env.PORT); // a number, 3000 unless PORT is set

Add a line to the schema in src/env.ts when you add a variable to .env.

src/routes/health.ts

A GET /health endpoint:

import { Request, Response, Router } from "express";

const router: Router = Router();

router.get("/", (_req: Request, res: Response) => {
  res.status(200).json({
    status: "ok",
    timestamp: new Date().toISOString(),
  });
});

export default router;

Commands

npm run dev        # tsx watch src/index.ts (hot reload, NODE_ENV=development)
npm run build      # tsc (compile to dist/)
npm run start      # node dist/index.js (NODE_ENV=production)
npm run lint       # eslint src
npm run lint:fix   # eslint src --fix
npm run format     # prettier --write "src/**/*.ts"
npm run type:check # tsc --noEmit

With bun as the package manager, dev runs bun --watch src/index.ts instead.


Dependencies

PackageRole
expressHTTP framework
corsCross-origin resource sharing
helmetSecurity headers
morganHTTP request logger
dotenvLoads .env into process.env
zodValidates environment variables in src/env.ts
cross-envSets NODE_ENV in the dev and start scripts

Environment Variables

PORT=3000
CORS_ORIGIN=*

CORS_ORIGIN sets which sites may call the API from a browser. * allows every origin. For production, list the origins of your frontends, separated by commas: CORS_ORIGIN=https://app.example.com,https://admin.example.com.

NODE_ENV is not written to .env. The dev script sets it to development and start sets it to production (through cross-env, so it works on Windows too).

Additional variables are added by the database and auth modules.


Integration with Other Modules

+ Database module

If a database module is selected, the build adds:

  • A database client singleton (src/lib/prisma.ts or src/lib/db.ts)
  • The relevant ORM package and scripts

+ Auth module

If auth is selected, the build adds:

  • src/lib/jwt.ts - JWT sign/verify utilities
  • src/middleware/requireAuth.ts - Bearer token validation middleware
  • src/services/userStore.ts - User lookup helper
  • src/controllers/authController.ts - Register/login handlers
  • src/routes/auth.ts - Router mounted at /auth
  • JWT_SECRET and JWT_EXPIRES_IN environment variables

+ Rate limiting

If rate limiting is selected (--rate-limit), the build adds src/middleware/rateLimit.ts with express-rate-limit:

  • Every route except /health allows 100 requests per client IP in 15 minutes.
  • With auth, POST /auth/register and POST /auth/login allow 10 attempts per IP in 15 minutes, which slows down password guessing.

A client over the limit gets 429 Too Many Requests, and limited responses carry RateLimit headers. The limits are counted in memory, so each server instance counts separately. Behind a reverse proxy or load balancer, add app.set("trust proxy", 1) so the limit uses the real client IP.

+ Docker module

If Docker is selected, the build adds Dockerfile, docker-compose.yml, and .dockerignore plus four docker:* scripts.


Extending the Backend

Add a route

  1. Create src/routes/users.ts
  2. Implement your route handlers
  3. Register the router in src/index.ts:
import usersRouter from "./routes/users";

app.use("/users", usersRouter);

Use the requireAuth middleware

import { Router } from "express";

import { requireAuth } from "../middleware/requireAuth";

const router = Router();

router.get("/profile", requireAuth, (req, res) => {
  res.json({ message: "Protected route" });
});

export default router;

NestJS

Select NestJS in the framework prompt, or pass --framework nestjs. The project uses NestJS 12 with ES modules, so relative imports end in .js.

src/
|-- main.ts                 # Entry point - loads .env, enables helmet and CORS, starts the server
|-- env.ts                  # Validates environment variables at startup
|-- app.module.ts           # Root module - imports the database and auth modules you selected
\-- health/
    \-- health.controller.ts   # GET /health endpoint

Scripts

ScriptCommand
devnest start --watch with NODE_ENV=development
buildnest build (output in dist/)
startnode dist/main.js with NODE_ENV=production
linteslint src
type:checktsc --noEmit

Database modules

DatabaseFilesHow to use it
Prismasrc/prisma/prisma.service.ts, src/prisma/prisma.module.tsInject PrismaService
Drizzlesrc/database/database.module.ts, src/database/schema.ts@Inject(DRIZZLE) private readonly db: Database
Mongoosesrc/users/user.schema.ts, connection in src/app.module.tsMongooseModule.forFeature(...) and @InjectModel(User.name)

The Prisma and Drizzle modules are global, so any module can inject them without importing them.

Auth module

JWT auth adds src/auth/ with the same endpoints as Express: POST /auth/register, POST /auth/login, and GET /auth/me. Request bodies are validated with Zod.

With rate limiting (--rate-limit), the build adds @nestjs/throttler: a global guard allows 100 requests per client IP in 15 minutes, and register and login allow 10. Change the limits in src/app.module.ts and src/auth/auth.controller.ts.

src/env.ts and CORS_ORIGIN work as they do for Express.

Protect a route with the guard:

import { Controller, Get, Req, UseGuards } from "@nestjs/common";

import { type AuthenticatedRequest, AuthGuard } from "../auth/auth.guard.js";

@Controller("profile")
export class ProfileController {
  @Get()
  @UseGuards(AuthGuard)
  getProfile(@Req() request: AuthenticatedRequest) {
    return { user: request.user };
  }
}

On this page