Skip to content

Server Setup#

Learn how to structure a production-ready Express server with TypeScript, CORS, middleware, and routers.

Server Class Architecture#

The Server class pattern provides a clean, organized way to structure your Express application.

import express, { Router } from "express";
import cors from "cors";
import morgan from "morgan";
import path from "path";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth/auth.js";
import logger from "./utils/logger.js";
import type { Config } from "./config.js";

export class Server {
  app: express.Express;
  subrouters: { [key: string]: Router } = {};
  config: Config;

  constructor(config: Config) {
    this.app = express();
    this.config = config;

    // Setup middleware and routes
    this.setupCORS();
    this.setupLogging();
    this.setupAuth();
    this.setupMiddleware();
    this.setupRoutes();
    this.setupErrorHandlers();
  }

  private setupCORS() {
    const whitelist = [this.config.frontendUrl, ...this.config.otherAllowedOrigins];

    this.app.use(
      cors({
        origin: function (origin, callback) {
          console.log("Request coming from", origin);
          if (whitelist.indexOf(origin!) !== -1 || !origin) {
            callback(null, true);
          } else {
            callback(new Error("Not allowed by CORS"));
          }
        },
        credentials: true, // Allow cookies, authorization headers
      })
    );
  }

  private setupLogging() {
    this.app.use(
      morgan(this.config.devMode ? "dev" : "combined", {
        stream: {
          write: (msg: string) => {
            logger.http(msg.trim());
          },
        },
      })
    );
  }

  private setupAuth() {
    // Better Auth handler - MUST come before express.json()
    this.app.all("/api/auth/*", toNodeHandler(auth));
  }

  private setupMiddleware() {
    // Mount express.json() AFTER Better Auth handler
    this.app.use(express.json());

    // Health check endpoint
    this.app.get("/api/healthcheck", (req, res) => {
      res.json({ status: "available", apiVersion: "v1" });
    });

    // Static file serving
    this.app.use(
      "/uploads",
      express.static(path.join(process.cwd(), "uploads"))
    );
  }

  private setupRoutes() {
    // Define your routers
    this.subrouters = {
      "/api/users": usersRouter,
      "/api/treks": treksRouter,
      "/api/posts": postsRouter,
    };

    // Dev-only routes
    if (this.config.devMode) {
      this.app.use("/api/tmp", tmpRouter);
    }

    // Auto-register all subrouters
    Object.entries(this.subrouters).forEach(([prefix, router]) => {
      this.app.use(prefix, router);
    });
  }

  private setupErrorHandlers() {
    // 404 handler (must come before error handler)
    this.app.use(handler404);

    // Global error handler (must be last)
    this.app.use(errorHandler);
  }

  start() {
    this.app.listen(this.config.port, "0.0.0.0", () => {
      logger.info(`Server running on 0.0.0.0:${this.config.port}...`);
    });
  }
}

Usage#

// config.ts
export interface Config {
  port: number;
  devMode: boolean;
  frontendUrl: string;
  otherAllowedOrigins: string[];
}

export const config: Config = {
  port: Number(process.env.PORT) || 3000,
  devMode: process.env.NODE_ENV !== "production",
  frontendUrl: process.env.FRONTEND_URL || "http://localhost:5173",
  otherAllowedOrigins: process.env.OTHER_ALLOWED_ORIGINS?.split(",").filter(
    (u) => u.trim() !== ""
  ) || [],
};

// index.ts
import { Server } from "./server.js";
import { config } from "./config.js";

const server = new Server(config);
server.start();

CORS Configuration#

Allow Multiple Origins#

// Parse comma-separated origins from environment
const otherAllowedOrigins = 
  process.env.OTHER_ALLOWED_ORIGINS?.split(",").filter(
    (u) => u.trim() !== ""
  ) || [];

const whitelist = [config.frontendUrl, ...otherAllowedOrigins];

app.use(
  cors({
    origin: function (origin, callback) {
      // Allow requests with no origin (like mobile apps or curl)
      if (!origin) return callback(null, true);

      if (whitelist.indexOf(origin) !== -1) {
        callback(null, true);
      } else {
        callback(new Error("Not allowed by CORS"));
      }
    },
    credentials: true,
  })
);

Environment Variables#

# .env
FRONTEND_URL=http://localhost:5173
OTHER_ALLOWED_ORIGINS=http://localhost:3001,https://staging.example.com

Middleware Order (Important!)#

// ✅ Correct order
app.use(cors());                    // 1. CORS first
app.use(morgan());                  // 2. Logging
app.all("/api/auth/*", authHandler); // 3. Better Auth
app.use(express.json());            // 4. JSON parser (after auth!)
app.use("/api/users", usersRouter); // 5. Routes
app.use(handler404);                // 6. 404 handler
app.use(errorHandler);              // 7. Error handler (last!)

⚠️ Important: Place express.json() after Better Auth handler, or it will interfere with auth requests!

Router Structure#

Create Feature Routers#

// routes/users.ts
import { Router } from "express";
import type { Request, Response } from "express";

const router = Router();

router.get("/", async (req: Request, res: Response) => {
  // Get all users
  res.json({ users: [] });
});

router.get("/:id", async (req: Request, res: Response) => {
  // Get user by ID
  res.json({ user: {} });
});

router.post("/", async (req: Request, res: Response) => {
  // Create user
  res.status(201).json({ user: {} });
});

export { router as usersRouter };

Register Routers#

// Manually
app.use("/api/users", usersRouter);
app.use("/api/posts", postsRouter);

// Or automatically with subrouters object
const subrouters = {
  "/api/users": usersRouter,
  "/api/posts": postsRouter,
  "/api/comments": commentsRouter,
};

Object.entries(subrouters).forEach(([prefix, router]) => {
  app.use(prefix, router);
});

Static File Serving#

import path from "path";

// Serve uploads folder
app.use(
  "/uploads",
  express.static(path.join(process.cwd(), "uploads"))
);

// Now files are accessible at:
// http://localhost:3000/uploads/profile-pic.jpg

Development Utilities#

Route Printer#

Useful for debugging - prints all registered routes:

printRoutes() {
  console.log("\nRoutes registered directly:");

  this.app._router?.stack.forEach((layer: any) => {
    if (layer.route) {
      const path = layer.route.path;
      const methods = Object.keys(layer.route.methods)
        .map((m) => m.toUpperCase())
        .join(", ");
      console.log(`${methods.padEnd(10)} ${path}`);
    }
  });

  Object.entries(this.subrouters).forEach(([prefix, router]) => {
    console.log(`\nRoutes for prefix: ${prefix}`);
    router.stack.forEach((layer: any) => {
      if (layer.route) {
        const path = prefix + layer.route.path;
        const methods = Object.keys(layer.route.methods)
          .map((m) => m.toUpperCase())
          .join(", ");
        console.log(`${methods.padEnd(10)} ${path}`);
      }
    });
  });
}

// Usage
const server = new Server(config);
server.printRoutes();
server.start();

Output:

Routes registered directly:
GET        /api/healthcheck

Routes for prefix: /api/users
GET        /api/users
GET        /api/users/:id
POST       /api/users
PUT        /api/users/:id
DELETE     /api/users/:id

Best Practices#

  1. Organize by feature - Group related routes in separate router files
  2. Use subrouters - Prevents route duplication and keeps code DRY
  3. CORS whitelist - Use environment variables for allowed origins
  4. Middleware order matters - Especially auth before JSON parser
  5. Error handlers last - 404 and error handlers must be registered last
  6. Dev-only routes - Use if (config.devMode) for testing endpoints
  7. Health check - Always include /api/healthcheck for monitoring

Next Steps#