Why I built this the hard way
Most Node.js tutorials teach concepts in isolation — a routing demo here, an auth snippet there — and you never see how the pieces actually sit together in a real app. So instead of collecting disconnected exercises, I built one evolving codebase: a small e-commerce shop (car dealership catalog, cart, checkout) that grows a new production concern every module. By the time I got to module 29, the "toy" project had 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 — I could cd into any module and run it standalone, which made it easy to compare "before" and "after" when a new concern got layered in.
The architecture decisions that actually mattered
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, made the trade-off concrete instead of theoretical: 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 you.
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, I used 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: hosted Checkout means Stripe carries PCI compliance, and I get card payments working correctly in an afternoon instead of a week — the right trade for a project where payment UI customization wasn't the point. 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 meant I could feel the actual difference — Sequelize's migrations and associations versus Mongoose's schema flexibility and population — rather than picking one ORM by reputation.
Hardening the last module
Module 29 is where the app stops being a learning exercise and starts looking like something you'd actually deploy. The final app.js layers in the middleware every production Express app needs and none of the earlier modules had:
| Concern | Package | What it does |
|---|---|---|
| Security headers | helmet | Sets safe defaults for CSP, X-Frame-Options, etc. |
| Response size | compression | Gzips responses |
| Request logging | morgan | Writes combined-format logs to access.log |
| Secrets | dotenv | Pulls 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 I had 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 project isn't about any single clever trick — it's evidence of range across the layers a backend engineer needs to own: 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 I take on backend work, this is the muscle memory I'm drawing on — not "I once followed a tutorial on X," but "I built X, then rebuilt it against a different constraint, and know which one to reach for."