Node.js
Updated
7 min read

A Modern Approach to Secure APIs with Node.js, Express, TypeScript, and ESM

Build a modern, secure Node.js API with Express, TypeScript, and ESM. Learn to configure TypeScript with Express, enable hot-reloading with nodemon, and secure your API using Arcjet for rate-limiting and bot protection.

A Modern Approach to Secure APIs with Node.js, Express, TypeScript, and ESM

Node.js and Express have long been a go-to stack for building fast, flexible, and scalable web applications. While setting up a basic Express app is well-documented and straightforward, things start to get more complex when you introduce TypeScript and ECMAScript Modules (ESM) into the mix. The official Express documentation, while comprehensive, doesn't offer much guidance on how to integrate TypeScript, leaving developers to navigate the complexities of configuring it on their own.

The combination of TypeScript's type safety and better tooling can make a huge difference in long-term maintainability and code quality, but getting everything to play nicely together is not without its challenges. Add in the growing adoption of ESM (especially as more tools like Arcjet are published as ESM-only packages), and you're suddenly dealing with issues like module resolution and import syntax conflicts.

This post will guide you through the process of setting up a secure and modern Node.js app with Express and TypeScript with ESM support, enabling hot-reloading when files change, secured with Arcjet.

This article is also available as a video. Scroll down to continue reading the full step-by-step guide, or watch the video here:

Initialize the Project

To get started, we’ll create a new directory and initialize a new Node.js project inside it:

mkdir node-express-api
cd node-express-api
npm init -y

This will generate a package.json file with default settings.

Install Dependencies

Install Express.js and TypeScript-related packages:

npm install express
npm install --save-dev typescript ts-node @types/express
  • express: The web framework
  • typescript and ts-node: To enable TypeScript support
  • @types/express: TypeScript definitions for Express

Configure TypeScript

Now that we have the basic dependencies installed, we need to configure TypeScript for our project. Let’s initialize our TypeScript configuration:

npx tsc --init

This will generate a tsconfig.json file in your project’s root directory. We need to tweak a few settings to make sure everything works as expected. Update the tsconfig.json file:

{
  "compilerOptions": {
    "module": "ESNext", // Use ESNext module system (ES modules)
    "target": "ESNext", // Compile to the latest ECMAScript version
    "moduleResolution": "node", // Resolve modules using Node.js style
    "esModuleInterop": true, // Enables interop between ES modules and CommonJS
    "allowSyntheticDefaultImports": true, // Allows default imports from modules with no default export
    "strict": true, // Enables strict type-checking
    "skipLibCheck": true, // Skip type-checking declaration files (faster builds)
    "outDir": "./dist" // Specifies output directory for compiled files
  },
  "include": ["src/**/*"] // Include all files under the src directory
}

Set Up the Express API Server

Next, we'll create a basic Express.js server with TypeScript. Create the src directory, and an entry-point file:

mkdir src
touch src/app.ts

Edit the src/app.ts file to start an Express server:

import express from "express";

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.status(200).json({ message: "Hello World" });
});

app.listen(port, () => {
  console.log(`App listening on http://localhost:${port}`);
});

This code sets up a basic Express server that listens on port 3000, and includes a single route that responds with a JSON-formatted welcome message.

Enable Hot-Reloading with nodemon

When working in a development environment, it’s helpful to have the server automatically restart whenever you make changes to your code. This can be accomplished using nodemon, which watches your files and restarts the server as needed.

First, install nodemon as a development dependency:

npm install --save-dev nodemon

Next, create a nodemon.json file to configure nodemon to watch for changes in your TypeScript files:

touch nodemon.json

Add the following configuration to nodemon.json:

{
  "watch": ["src"],
  "ext": "ts",
  "ignore": ["dist"],
  "exec": "node --loader ts-node/esm src/app.ts"
}

This setup ensures that nodemon will automatically restart the server whenever a .ts file changes in the src directory. The exec command runs the server using the ts-node loader to transpile TypeScript on the fly and handle ES Modules.

Now, add a script to your package.json to run nodemon:

{
  "scripts": {
    "dev": "nodemon"
  }
}

Update package.json for ES Modules

To ensure our project fully supports ES Modules, we need to make a few small updates to package.json.

Remove "main": "index.js": this specifies the entry point of your Node.js application or package. As we’re not creating a library or package, this line is irrelevant.

Add "type": "module": This tells Node.js to treat all files as ES Modules, so you can use the modern import/export syntax throughout your project.

Your package.json should now start like this:

{
  "name": "node-express-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "nodemon",
    ...

Test the Server

At this point, you can start your development server using npm run dev. This will launch your Express server and automatically reload it whenever you modify your TypeScript files.

You can test it by navigating to http://localhost:3000 in your browser. You should see: {"message":"Hello World"}.

Screenshot of the example Node.js + Express app displaying {"message":"Hello World"}

Integrate Arcjet for Security

Now that we have a functional TypeScript-based Node.js and Express server, it's time to add Arcjet to enhance the security of our application. Arcjet provides middleware for securing APIs by mitigating threats such as bot attacks, abuse, and other web security risks.

First, install the Arcjet package for Node.js:

npm install @arcjet/node

Create a free Arcjet account, add a site, and get your key. You'll need to add this key and configure the environment in a .env.local file in the root of your project:

# NODE_ENV is not set by the environment, so tell Arcjet we're in dev
ARCJET_ENV=development

# Get your site key from https://app.arcjet.com
ARCJET_KEY=ajkey_..........................

We’ll configure Arcjet as middleware to provide security across our application. Open src/app.ts and update it with the following changes:

import express from "express";
import arcjet, { detectBot, fixedWindow, shield } from "@arcjet/node";

const app = express();
const port = 3000;

// Initialize Arcjet
const aj = arcjet({
  key: process.env.ARCJET_KEY!,
  rules: [
    shield({
      mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
    }),
    detectBot({
      mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
      block: ["AUTOMATED"], // blocks requests from automated bots
    }),
    fixedWindow({
      mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
      window: "1m", // 1-minute window
      max: 1, // allow a single request
    }),
  ],
});

// Call Arcjet before your routes
app.use(async (req, res, next) => {
  const decision = await aj.protect(req);
  
  // Check if the request is denied
  if (decision.isDenied()) {
    if (decision.reason.isRateLimit()) {
      return res.status(429).json({ message: "Too many requests" });
    } else {
      return res.status(403).json({ message: "Access denied" });
    }
  }
  
  // Call next middleware
  next();
});

app.get("/", (req, res) => {
  res.status(200).json({ message: "Hello World" });
});

app.listen(port, () => {
  console.log(`App listening on http://localhost:${port}`);
});

Update nodemon to Load Environment Variables

To ensure the environment variables are loaded in development, we need to update the nodemon.json configuration to use Node's --env-file flag:

{
  "watch": ["src"],
  "ext": "ts",
  "ignore": ["dist"],
  "exec": "node --env-file .env.local --loader ts-node/esm src/app.ts"
}
💡
Node 20+ supports loading environment variables from a local file with --env-file. If you’re using an older version of Node, you can use a package like dotenv to load the environment variables from a .env.local file.

Retest the Server

If you’re still running npm run dev in the background, you’ll need to restart it. While changes to .ts files in the src directory will cause a reload, we’ll have to do it manually this time to pull in the environment variables and pass them to node.

Now head to http://localhost:3000 again and you should see the same {"message":"Hello World"} response. If you hit refresh within the minute, the message should change to {"error":"Too Many Requests"}:

Screenshot of the example Node.js + Express app displaying {"error":"Too Many Requests"}

Building for Production

Now that you've built a modern Node.js API using TypeScript, ESM, and Arcjet for enhanced security, it's time to prepare your application for production. Let’s set up two npm scripts to build and start your application. Add the following two scripts to your package.json:

{
  "scripts": {
    "dev": "nodemon",
    "build": "tsc",
    "start": "node dist/app.js"
  },

To compile your TypeScript files, simply execute npm run build which will execute the TypeScript compiler (tsc). The compiler will take all the TypeScript files in the src directory, convert them to JavaScript, and output them to the dist directory.

Once your application is compiled, you're ready to start the production server using npm start which will run the compiled JavaScript file located in the dist directory. By running the JavaScript output directly, your production environment remains lightweight and optimized for performance.

Congratulations!

You've successfully set up a development environment where you can confidently build, refine, and test your API. With this setup, you're able to fine-tune security measures like rate-limiting and bot protection and observe their effects directly in development before moving to production.

The integration of TypeScript adds another layer of reliability, catching potential issues early, improving code quality, and making your API easier to maintain and scale as your project grows.

This powerful combination of security and type safety ensures that your API is not only secure but also robust and future-proof, ready for whatever challenges lie ahead when it’s time to go live.

Related articles

Subscribe by email

Get the full posts by email every week.