Designing a Serverless Express.js API using Zeit Now

Brooks Mikkelsen
5 min readJan 14, 2019

--

Serverless lambdas are great for single REST API endpoints. They’re easy to set up, there’s much less hassle than setting up multiple server instances and load balancers, and they’re cheaper in many cases. There are plenty of tutorials online that’ll walk you through setting one up. But what about using a lambda to handle multiple endpoints, such as an entire route in a larger API?

Our goal: We will build an Express.js API with multiple routes, each of which is handled by a single lambda. The lambdas will be deployed using Zeit Now, and only contain the code needed to respond to requests in a particular route. We will also create a development server, because support for running lambdas locally for debugging is poor.

Why would we want to do this in the first place?

  • Code sharing: Chances are that all of the endpoints within a given route will use the same data model to back them, or at least some shared code. Putting an entire route in the same lambda will decrease the amount of boilerplate code (such as database connection) without having to load our entire API to respond to a single request.
  • All the functionality of Express: Express comes with a lot of great features to keep your code concise easy to follow. Their API is much easier to use than the traditional Node HTTP Server API that Now uses by default. Plus you get support for middleware, which will further cut down on repeated code.
  • Fewer cold starts: A “cold start” occurs when a lambda hasn’t been invoked for a while, causing it to take longer to respond to a request. When the same lambda is used to respond to multiple endpoints, it will statistically get invoked more often. Since Zeit Now caches lambdas for up to 15 minutes, the odds are greater that it will get invoked again before it gets unloaded from the cache.
  • Serverless paradigm: Zeit recommends that we use @now/node instead of @now/node-server because it adheres to the idea that serverless lambdas are really just functions. We can use @now/node because we are simply exposing a function to run, rather than creating the server instance ourselves. This way, we’re sticking to the traditional serverless paradigm.
  • Smaller memory footprint: One popular solution is to put an entire API into the same lambda. While this is possible and will work, it increases the required memory significantly, which will increase your operating costs. Splitting each route into its own lambda will mitigate this by running less code that isn’t needed for the request.

How does this work?

A traditional Express app looks like this:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
res.send('Hello World')
});

app.listen(3000);

We instantiate a new Express app, define our middleware and routes, and listen on a specified port. But we don’t want to set up the actual HTTP server; that’s handled for us by the lambda. Specifically, Zeit Now lambdas look something like this:

module.exports = (req, res) => {
res.end('Hello World');
};

We just export a function that can be consumed by a traditional Node HTTP server. Note that in this case, req is of type IncomingMessage, and res is of type ServerResponse, which are different than the Express Request and Response. So how do we get our Express app into that shape? As it turns out, it already is. Another way to set up an Express server is to use the http package’s createServer() function:

const express = require('express');
const http = require('http');

const app = express();
app.get('/', (req, res) => {
res.send('Hello World')
});
const server = http.createServer(app);
server.listen(3000);

This means that an Express app , at its core, is a function that matches the (IncomingMessage, ServerResponse) => void type signature that @now/node is looking for. Therefore, we can just export app rather than starting the server on a given port.

What would this look like in practice?

src/routes/book.js

const { Router } = require('express');
// ...other imports...
const router = Router();// add your middleware here so it will be used on dev server too
router.use(AuthHandler);
router.use(bodyParser.json());
// endpoints in route
router.get('/:bookId', async (req, res) => {
const book = await BookModel.getBook(req.params.bookId);
if (!book) {
return res.sendStatus(404);
}
return res.json(book);
});
// ...more endpoints...module.exports = router;

Here we actually define the route and add endpoints. This file should look mostly the same as a traditional Express route file. Note that we add all of our middleware here, since there won’t be an overarching app file where we can add universal middleware.

src/lambdas/book.js

const express = require('express');
const mongoose = require('mongoose');
const bookRouter = require('../routes/book');
const { MONGO_ADDRESS } = process.env;
// set up db connection
mongoose.connect(MONGO_ADDRESS);
mongoose.Promise = global.Promise;
const app = express();// no need to route only /book requests; that's done in now.json
app.use(bookRouter);
// just export the app instead of starting up the server
module.exports = app;

This is the entry point for our lambda. We connect to our database, create an Express app, and add our route to it. If you have a lot of setup to do, this could be factored into another module that’s reused in each lambda. Note that we’ll specify that this lambda only responds to requests on the /book route in now.json, so we don’t need to do so here.

now.json

{
"version": 2,
"name": "book-api",
"builds": [
{ "src": "/src/lambdas/book.js", "use": "@now/node" }
// ...more builds here - one for each route
],
"routes": [
{ "src": "/book/(.*)", "dest": "/src/lambdas/book.js" }
// ...more routes here. "src" is how the user will access it
],
"env": {
"MONGO_ADDRESS": "@mongo-address" // @ represents a secret
}
}

This file tells Zeit Now how to set up our lambdas. The builds array contains all of the entrypoints and what to do with them (we’ll use @now/node for each route). The routes array matches each request to a given lambda using regex. The env object contains environment variables for use in the lambda. @ signifies a secret that is configured via the command line with Now so that this file can be source controlled.

src/dev.js

require('dotenv').config(); // load env vars from .env file
const express = require('express');
const bookRoute = require('./routes/book');
// import other routes...
const { MONGO_ADDRESS } = process.env;
// set up db connection
mongoose.connect(MONGO_ADDRESS);
mongoose.Promise = global.Promise;
const app = express();// add all of the routes in the API here
app.use('/book', bookRoute);
// ...more routes here...
app.listen(3000);

One drawback to serverless functions is the ease of debugging. Since hosting companies manage how our lambdas are run, it’s hard to mimic the environment. This file sets up a basic server for debugging our API. It’s not perfect, so we’ll need to make a .env file with our connection information (don’t source control this!). Note that in this case, we won’t have Zeit Now to do routing for us, so we’ll need to specify the path to each route.

Deploying our API

This API is just a skeleton to give you an idea of what the project structure could look like. It’s not ready to be deployed, but for completeness, let’s walk through the steps to deploy it with Zeit Now.

  1. Install Zeit Now command line interface using NPM: npm i now -g .
  2. Add a secret to Now: now secret add mongo-address <your address>. Here, mongo-address is the name of the secret. Now will ask you to login to your account if you haven’t already.
  3. Deploy it! Just run now . Once it’s deployed, Now will output a URL to your API.

--

--