Building a simple TypeScript REST API with Node.js

Below we’ll walk through a practical TypeScript application in Node.js by creating a sample REST API that implements a basic CRUD model.

Project setup and dependency installation

Let’s start by initializing an empty folder as a Node project. This command generates a basic package.json that we can complete later. Right after, we install the runtime dependencies—Express to handle routes, CORS for cross-origin permissions, and Helmet to add some security headers. Finally, we install the dev dependencies: TypeScript, type definitions for Node and Express, and TSX to quickly run TypeScript files without an explicit build during development.

npm init -y
npm i express cors helmet
npm i -D typescript tsx @types/node @types/express @types/cors

Defining scripts in package.json

Now let’s add a few scripts useful for day-to-day development. dev starts the app with auto-reload thanks to TSX, build compiles to JavaScript into the dist folder, and start runs the compiled code. The type field is set to module to use ES modules, which are a good fit with modern TypeScript.

{
  "name": "mock-api",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p .",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.19.2",
    "helmet": "^7.1.0"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/node": "^22.7.0",
    "tsx": "^4.16.2",
    "typescript": "^5.5.4"
  }
}

Configuring TypeScript with tsconfig.json

To control how TypeScript compiles the code, we add a tsconfig.json file. We set a modern ECMAScript target, define src as the source folder and dist as the output. We enable strict options to prevent common errors and speed up compilation with skipLibCheck when possible.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Creating the entry point: src/index.ts

At this point we create the application’s entry file. We import Express and configure middleware to parse incoming JSON. We import and apply CORS with an open configuration to simplify development, leaving a comment on how to tighten it later. We add Helmet for some security headers and mount a dedicated router for our mock resource. We also include a healthcheck endpoint to quickly verify the server is responding. Finally, we start the server on a port that can be configured via environment variable.

import express from "express";
import cors from "cors";
import helmet from "helmet";
import { mockRouter } from "./mock.routes.js";

const app = express();

app.use(cors({
  origin: "*",
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"]
}));

app.use(helmet());
app.use(express.json());

app.use("/api/v1/mock", mockRouter);

app.get("/health", (_req, res) => {
  res.json({ status: "ok" });
});

const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => {
  console.log(`Mock API ready at http://localhost:${PORT}`);
}); 

Implementing the mock resource: src/mock.routes.ts

For the actual resource we create a separate Express router, keeping the code modular and easy to extend. We define a TypeScript type for the resource items and prepare a small in-memory dataset. Each handler manages one CRUD operation: list, detail, create, update, and delete. Since the data is in memory, everything is lost on every restart—useful for prototypes and tests.

import { Router } from "express";

type MockItem = {
  id: string;
  name: string;
  value: number;
};

const data: MockItem[] = [
  { id: "1", name: "Alpha", value: 42 },
  { id: "2", name: "Beta", value: 7 }
];

export const mockRouter = Router();

mockRouter.get("/", (_req, res) => {
  res.json({ items: data, count: data.length });
});

mockRouter.get("/:id", (req, res) => {
  const item = data.find(i => i.id === req.params.id);
  if (!item) return res.status(404).json({ error: "Not found" });
  res.json(item);
});

mockRouter.post("/", (req, res) => {
  const { name, value } = req.body ?? {};
  if (typeof name !== "string" || typeof value !== "number") {
    return res.status(400).json({ error: "Invalid payload: { name: string, value: number }" });
  }
  const id = (Math.max(0, ...data.map(d => Number(d.id))) + 1).toString();
  const item: MockItem = { id, name, value };
  data.push(item);
  res.status(201).json(item);
});

mockRouter.put("/:id", (req, res) => {
  const idx = data.findIndex(i => i.id === req.params.id);
  if (idx === -1) return res.status(404).json({ error: "Not found" });

  const { name, value } = req.body ?? {};
  if (typeof name !== "string" || typeof value !== "number") {
    return res.status(400).json({ error: "Invalid payload: { name: string, value: number }" });
  }
  data[idx] = { id: data[idx].id, name, value };
  res.json(data[idx]);
});

mockRouter.delete("/:id", (req, res) => {
  const idx = data.findIndex(i => i.id === req.params.id);
  if (idx === -1) return res.status(404).json({ error: "Not found" });
  const [removed] = data.splice(idx, 1);
  res.json({ deleted: removed.id });
}); 

CORS and security considerations

The configuration used enables access from any origin, which is ideal during development but not suitable for a production-exposed environment. To restrict origins, you can replace the origin option with a function that authorizes only the expected domains, or with an array of strings. When you decide to send cookies or credentials across domains, you must set credentials: true and specify an explicit origin, because the wildcard is not compatible with that mode.

import cors from "cors";

const allowed = ["http://localhost:5173", "[https://app.example.com](https://app.example.com)"];
app.use(cors({
  origin: (origin, cb) => {
  if (!origin || allowed.includes(origin)) return cb(null, true);
    return cb(new Error("Not allowed by CORS"));
  },
  credentials: true
})); 

Running the application in development and production

With the files created, we can install dependencies and launch the server in watch mode, which is useful for iterating quickly on the code. Alternatively, we can run a build and start the compiled version, which more closely mirrors production behavior.

npm i
npm run dev
# alternatively
npm run build
npm start

Checking endpoints with cURL

To verify that everything responds correctly, we can use a few cURL commands. The list endpoint returns the full dataset, the detail endpoint filters by identifier, while POST, PUT, and DELETE respectively create, update, and delete items from the in-memory mock.

curl http://localhost:3000/api/v1/mock
curl http://localhost:3000/api/v1/mock/1
curl -X POST http://localhost:3000/api/v1/mock -H "Content-Type: application/json" -d '{"name":"Gamma","value":123}'
curl -X PUT http://localhost:3000/api/v1/mock/1 -H "Content-Type: application/json" -d '{"name":"Alpha+","value":99}'
curl -X DELETE http://localhost:3000/api/v1/mock/2

Quickly handling common issues with ESLint and TypeScript

If you encounter an error related to dependencies between ESLint and the TypeScript plugin—typically because the plugin requires a different ESLint major—the most straightforward path is to align major versions. If you use ESLint nine, it’s better to install version eight of the @typescript-eslint package; if instead you prefer to keep version seven of the plugin, you should downgrade ESLint to version eight. After aligning versions, it’s often enough to remove the node_modules folder and the package-lock.json file, then reinstall to clean up dependency resolution.

npm i -D eslint@^9 @typescript-eslint/parser@^8 @typescript-eslint/eslint-plugin@^8
# alternatively
npm i -D eslint@^8.56 @typescript-eslint/parser@^7 @typescript-eslint/eslint-plugin@^7

Conclusion

We’ve created a functional foundation for a TypeScript REST API with Express, complete with CORS and a mock resource that’s easy to query and extend. From here, you can introduce input validation, add structured logging, draft an OpenAPI schema, and set up integration tests. The key is having separated the app configuration from the resource router, keeping the project tidy and ready to grow without sacrificing initial simplicity.

Back to top