A Modern Approach to Secure APIs with Node.js, Express, TypeScript, and ESM
Ben Dechrai
Posted on August 23, 2024
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
andts-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"}
.
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 likedotenv
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"}
:
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.
Posted on August 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.