Guides
Updated 2026-04-24

Building a REST API with Express.js and Tunnelwise

Step-by-step guide to building a REST API with Express.js, testing it locally, and exposing it to the internet with Tunnelwise.

This guide walks you through building a REST API with Express.js — from project setup and proper HTTP semantics to testing it locally and exposing it to the internet with Tunnelwise.

Express.js is a minimal Node.js web framework that gives you routing, middleware, and an HTTP server without much else on top. It has been around since 2010, has one of the largest ecosystems in the Node.js world, and is still one of the most widely used options for building APIs. Its flexibility means you can structure your project however you want, which makes it a common starting point for learning backend development.

The Node.js ecosystem has other solid options — NestJS, Hapi, Fastify, and Koa are all worth knowing about. Each makes different tradeoffs around structure, performance, and developer experience. Express is a good starting point because it stays out of your way, and the patterns you learn here transfer to most of the others. We'll cover other frameworks in separate guides.

1. Project Setup

Initialize the project and install dependencies:

mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev nodemon

Add scripts to package.json:

"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}

Run npm run dev during development — nodemon restarts the server automatically on file changes.


2. Project Structure

my-api/
  src/
    routes/
      products.js
    middleware/
      logger.js
    index.js
  package.json

Keeping routes and middleware in separate files makes the codebase easier to navigate as it grows.


3. Setting Up the Server

Create src/index.js:

// src/index.js
const express = require("express")
const logger = require("./middleware/logger")
const productsRouter = require("./routes/products")

const app = express()
const PORT = process.env.PORT || 3000

// Middleware
app.use(logger)
app.use(express.json())

// Routes
app.use("/products", productsRouter)

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})

4. Logging Middleware

A simple request logger that records the method, path, status code, and response time — no external libraries needed.

// src/middleware/logger.js
function logger(req, res, next) {
  const start = Date.now()
  res.on("finish", () => {
    const duration = Date.now() - start
    console.log(
      `[${new Date().toISOString()}] ${req.method} ${req.path} ${res.statusCode}${duration}ms`
    )
  })
  next()
}

module.exports = logger

The res.on('finish', ...) callback fires after the response is sent, which is why it can log the status code. Logging inside the route handler would fire too early.


5. REST Endpoints

Create src/routes/products.js with all five standard endpoints:

VerbRoutePurposeStatus on success
GET/productsList all products200
GET/products/:idGet a single product200
POST/productsCreate a new product201
PUT/products/:idReplace a product200
DELETE/products/:idDelete a product204
// src/routes/products.js
const express = require("express")
const router = express.Router()

// In-memory store — resets on restart, fine for development
let products = []
let nextId = 1

// GET /products — list all
router.get("/", (req, res) => {
  res.json(products)
})

// GET /products/:id — get one
router.get("/:id", (req, res) => {
  const product = products.find((p) => p.id === parseInt(req.params.id))
  if (!product) return res.status(404).json({ error: "Product not found" })
  res.json(product)
})

// POST /products — create
router.post("/", (req, res) => {
  const { name, price } = req.body
  const product = { id: nextId++, name, price }
  products.push(product)
  res.status(201).json(product)
})

// PUT /products/:id — replace
router.put("/:id", (req, res) => {
  const index = products.findIndex((p) => p.id === parseInt(req.params.id))
  if (index === -1) return res.status(404).json({ error: "Product not found" })

  // PUT replaces the whole resource — all fields must be provided
  const { name, price } = req.body
  products[index] = { id: products[index].id, name, price }
  res.json(products[index])
})

// DELETE /products/:id — delete
router.delete("/:id", (req, res) => {
  const index = products.findIndex((p) => p.id === parseInt(req.params.id))
  if (index === -1) return res.status(404).json({ error: "Product not found" })
  products.splice(index, 1)
  res.sendStatus(204) // No content — don't return a body on 204
})

module.exports = router

PUT vs PATCH: PUT replaces the entire resource — the client must send all fields. PATCH applies a partial update — only the fields being changed. Use PATCH when you want to update a single field without touching the rest.


6. Testing Locally

Start the server:

npm run dev

Then test with curl:

# Create a product
curl -X POST http://localhost:3000/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget", "price": 9.99}'

# List all products
curl http://localhost:3000/products

# Get a single product
curl http://localhost:3000/products/1

# Replace a product
curl -X PUT http://localhost:3000/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget Pro", "price": 14.99}'

# Delete a product
curl -X DELETE http://localhost:3000/products/1

7. Exposing Your API with Tunnelwise

localhost:3000 is only reachable from your own machine. If you need to share the API with a teammate, test it from a mobile device, or connect it to any external service, you need a public HTTPS URL.

Tunnelwise is a macOS app that creates a secure tunnel from a public URL to your local port in one click.

  1. Open Tunnelwise on macOS
  2. Create a new tunnel pointing to port 3000
  3. Start the tunnel
  4. Copy the generated URL — it looks like https://testing-expressjs-123.tunnelwise.io

Your local server is now reachable from anywhere. Run the same requests from the local testing section, replacing http://localhost:3000 with your tunnel URL:

# Create a product
curl -X POST https://testing-expressjs-123.tunnelwise.io/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget", "price": 9.99}'

# List all products
curl https://testing-expressjs-123.tunnelwise.io/products

# Get a single product
curl https://testing-expressjs-123.tunnelwise.io/products/1

# Replace a product
curl -X PUT https://testing-expressjs-123.tunnelwise.io/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget Pro", "price": 14.99}'

# Delete a product
curl -X DELETE https://testing-expressjs-123.tunnelwise.io/products/1

The tunnel URL stays stable across restarts as long as the tunnel is not inactive for more than 5 days — so you can share it with teammates or configure it in external tools without updating it every session.

If you prefer a visual interface over curl, Hoppscotch is a free, browser-based alternative to Postman. Since it runs in the browser it can't reach localhost directly, but it works fine with your public Tunnelwise URL — no installation required.


Notes

  • In-memory store resets on restart. That's expected — the focus here is the API structure, not persistence. For a real app, replace the array with a database.
  • 204 No Content responses must not include a body. Sending a JSON body with res.status(204).json(...) will cause issues in some clients — use res.sendStatus(204) instead.