Why is our app built with NestJS?
Written by Amina Layco:
I’m the Lead Applications Engineer here at Surfboard. I was recently tasked with building our customer-facing web application from scratch. Because I’m a full-stack Javascript engineer in a fast-paced startup, I didn’t have time to consider Go, Rust, or anything where the learning curve would slow us down. Frontend? A statically-served Single Page App in React. Backend? NodeJS. Backend framework? ExpressJS because it was the most commonly used choice (although I haven’t used it in a while). All in Typescript. All in one repo.
And so I started my first week by coding our new app. I added Typescript to the project and I set up a statically-served Hello World React app with ExpressJS. CI/CD was sorted by Heroku pipelines. It was now time to write a GET endpoint receiving some query parameters:
type MetricsQuery = { fromDate: string; toDate: string; granularity: 'day' | 'week'; }; router.get('/metrics', async (req: Request, res: Response) => { const data = await getMetrics(req.query); return res.status(200).json({ data }); });
Easy, right? But endpoints need validation. req.query needs to have all the required fields. That’s fine, I added a custom validation middleware using class-validator. It looked something like this:
router.get('/metrics', validationMiddleware<MetricsQuery>('query'), async (req: Request, res: Response) => { const data = await getMetrics(req.query); return res.status(200).json({ data }); } );
Being 2021, I wanted my validation middleware to propagate types correctly to the handler’s request parameter.
router.get('/metrics', validationMiddleware<MetricsQuery>('query'), async (req: Request, res: Response) => { // expecting req.query to automatically be of type MetricsQuery const data = await getMetrics(req.query) return res.status(200).json({ data }); } );
However, default ExpressJS typings don’t pass type definitions from validation middleware. Was I asking too much? Maybe. Anyway, we would have to settle with casting query, params, and body request fields.
router.get('/metrics', validationMiddleware<MetricsQuery>('query'), async (req: Request, res: Response) => { const data = await getMetrics(req.query as MetricsQuery) // ew return res.status(200).json({ data }); } );
What a beautiful endpoint I had! It returned the data quickly with clear validation errors. Time to do some error handling: another middleware that needed writing.
The following code is a simplified version of what I had so far.
// main.ts const app = express(); app.use("/v1", routes); app.use(errorMiddleware) // errorMiddleware.ts const errorMiddleware = ( error: HttpException, req: Request, res: Response, next: NextFunction ) => { logger.error(error.message || "Unknown error"); res.status(error.status || 500).json({ message: 'Unknown error' }); }; // routes.ts const router = Router(); router.get("/metrics", validationMiddleware<MetricsQuery>('query'), async (req: Request, res: Response) => { const data = await getMetrics(req.query as MetricsQuery) return res.status(200).json({ data }); } );
My goal was simple: any unexpected error in the app would be logged by errorMiddleware.ts
.
I was probably late to the party, but I quickly learnt two horrible truths. Firstly, whenever getMetrics
caused an error, the server hung and crashed.
That was because ExpressJS middleware doesn’t handle Promise rejections. This was the second truth. Rejecting a promise won’t trigger Express’ error handlers. What?
Yes, as it happens, that is only part of a future ExpressJS 5 version. When was version 5 going to be released? No idea. An alpha is available, but we weren’t going to build the core of our web app on top of an unstable alpha.
So what to do? I had to make a decision soon, I had features to deliver. Here were my options:
- I could have gone ahead and made sure every route was wrapped on a try/catch block. But what if someone ever forgot to use the wrapper? The server would crash. Too risky.
- I could have used another Express alternative called Koa. I had been using it in previous projects and it supports Promises out-of-the-box. It’s also possible to write some complicated validation middleware to propagate the type to the request (called “context” in Koa). But it wasn’t elegant. It was 2021, there had to be something better.
- What about NextJS? I had used it before but it comes with too much magic.
So I started googling. Enter NestJS.
NestJS is Typescript first, built on top of ExpressJS. That gave me confidence because I knew I could count on the ExpressJS interface that I was already familiar with. It offers tons of functionality while having a lightweight philosophy: only install what you will actually use. Plus, look at all the documentation they have:
- Easy routing
- Promise support
- Input validation that propagates types
- Versioning
- Middlewares
- Error handling
- End-to-end testing with Supertest
- Serve static files
And so much more! OpenAPI spec generation, cron jobs, hot reload, rate limiting, caching, database library integrations. It’s as if they had read my mind. The only problem? It forces you to use classes and decorators. I asked in my circles if anyone was using it because I have never heard of NestJS before. No one that I knew was familiar with it either. I had a chat with our CTO and we decided that the pros outweighed the cons. This framework had everything we’ll ever need. So we migrated to NestJS. It didn’t take more than a day and everything just worked.
I must point out that there are more frameworks out there. AdonisJS and fastify might have gotten the job done as well, but they weren’t a love at first sight.
A few months later, we’re still happy with our decision. We easily integrated with GCP Pub/Sub, added Auth0, added cron jobs, customized exception handlers and added custom middlewares. We 100% recommend giving NestJS a try.
If you want to come try it out for real, we’re hiring talented front-end engineers all the time. Come talk to us!