Flex Engineers
4 min read

29 Modules, One Backend: How We Build Node.js Fundamentals From the Ground Up

Node.jsExpressMongoDBREST APIsBackend DevelopmentTechnical Mentoring

Why we build this the hard way

Most Node.js tutorials teach concepts in isolation — a routing demo here, an auth snippet there — and an engineer never sees how the pieces actually sit together in a real app. So instead of collecting disconnected exercises, our backend growth track builds one evolving codebase: a small e-commerce shop (car dealership catalog, cart, checkout) that grows a new production concern every module. By module 29, the "toy" project has sessions, database persistence, file uploads, payments, PDF generation, and a hardened deployment config — the same stack of concerns that show up in any real client backend.

The repo is organized as 29 numbered directories, each a runnable Express app, each one letter closer to production:

05. Express/          → routing, middleware basics
07. MVC/               → controllers, routes, views split apart
10. SQL/ 11. Sequelize/  → raw mysql2 queries, then an ORM
12. MongoDB/ 13. Mongoose/ → same migration, NoSQL side
14. Session_Cookies/   → express-session + connect-mongodb-session
15. Authentication/    → bcrypt hashing, protected routes
17. AdvancedAuth/       → password reset via email tokens
18. Validation/         → express-validator on every form
20. FileUploadDownload/ → multer, streamed downloads
21. Pagination/         → skip/limit with MongoDB cursors
23. Payments/           → Stripe Checkout + PDFKit invoices
27. Socket/             → REST API + JWT + Socket.io, React client
29. Deploy/             → helmet, compression, morgan, env config

Each folder is a working app with its own package.json — an engineer can cd into any module and run it standalone, which makes it easy to compare "before" and "after" when a new concern gets layered in, and easy for a mentor to review one concept at a time.

The architecture decisions that actually matter

Session auth for the storefront, JWT for the API. The shop app (modules 14–23) uses express-session backed by connect-mongodb-session, with CSRF protection on every form via csurf. That's the right call for a server-rendered EJS app where the browser is doing traditional form posts. But module 27 pivots to a decoupled React frontend talking to a JSON REST API — and for that, session cookies don't make sense across origins, so auth switches to JWT:

module.exports = (req, res, next) => {
  const authHeader = req.get('Authorization');
  if (!authHeader) {
    const error = new Error('Not Authenticated.');
    error.status = 401;
    throw error;
  }
  const token = authHeader.split(' ')[1];
  const decodedToken = jwt.verify(token, 'somesupersecret');
  req.userId = decodedToken.userId;
  next();
};

Building both in the same curriculum, back to back, makes the trade-off concrete instead of theoretical for the engineer going through it: sessions are simpler and get CSRF protection for free, but they couple you to server-rendered views and sticky sessions at scale; JWT decouples the client but pushes token expiry and revocation onto the team.

Real-time updates without polling. The same module 27 API broadcasts post create/update/delete events over Socket.io instead of making the React client poll:

io.getIO().emit('posts', {
  action: 'create',
  post: { ...post._doc, creator: { _id: req.userId, name: user.name } }
});

The socket.js module wraps socket.io's init behind a getIO() accessor so any controller can grab the same io instance without threading it through every function signature — a small pattern, but it's the difference between socket logic tangled through your route handlers and socket logic that stays at the edges.

Stripe Checkout over a custom payment form. For module 23, the curriculum uses Stripe's hosted Checkout Session rather than building a card form with Stripe Elements:

stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  line_items: products.map((p) => ({
    price_data: {
      currency: 'usd',
      unit_amount: p.productId.price * 100,
      product_data: { name: p.productId.title, description: p.productId.description },
    },
    quantity: p.quantity,
  })),
  mode: 'payment',
  success_url: req.protocol + '://' + req.get('host') + '/checkout/success',
  cancel_url: req.protocol + '://' + req.get('host') + '/checkout/cancel',
});

That's a deliberate scope call our engineers learn to make: hosted Checkout means Stripe carries PCI compliance, and card payments work correctly in an afternoon instead of a week — the right trade for a project where payment UI customization wasn't the point, and the exact call we make for clients who need to accept payments fast without owning PCI scope. Order confirmation then streams a generated invoice straight to the client with PDFKit, piping the same document to both a saved file and the HTTP response:

const pdfDoc = new PDFDocument();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="' + invoiceName + '"');
pdfDoc.pipe(fs.createWriteStream(invoicePath));
pdfDoc.pipe(res);

Two persistence layers, on purpose. Modules 10–13 build the same product-catalog CRUD twice: once with raw mysql2 queries and Sequelize on top, then again with MongoDB and Mongoose. Later modules standardize on Mongoose, but keeping both paths means an engineer can feel the actual difference — Sequelize's migrations and associations versus Mongoose's schema flexibility and population — rather than picking one ORM by reputation, which matters when a client's existing infrastructure dictates the choice for us.

Hardening the last module

Module 29 is where the app stops being a learning exercise and starts looking like something we'd actually deploy for a client. The final app.js layers in the middleware every production Express app needs and none of the earlier modules had:

ConcernPackageWhat it does
Security headershelmetSets safe defaults for CSP, X-Frame-Options, etc.
Response sizecompressionGzips responses
Request loggingmorganWrites combined-format logs to access.log
SecretsdotenvPulls MONGO_CONNECT, STRIPE_SKEY, PORT out of source
app.use(helmet());
app.use(compression());
app.use(morgan('combined', { stream: accessLogStream }));

None of these are exotic — that's the point. The value of building them into an already-working app, instead of scaffolding them from a starter template, is that the engineer has to reason about ordering (helmet and compression before body parsing, session middleware before the CSRF token gets attached to res.locals) rather than trusting a boilerplate to have gotten it right.

Takeaway

This program isn't about any single clever trick — it's evidence of the range we build into every backend engineer on the team: relational and document databases, session and token auth, file handling, third-party payment integration, real-time transport, and the unglamorous middleware that makes an app safe to put in front of real users. When we staff someone on backend client work, this is the muscle memory they're drawing on — not "once followed a tutorial on X," but "built X, then rebuilt it against a different constraint, and knows which one to reach for."