What a REST API Really Is
A REST API is nothing more than a web server that speaks plain HTTP and returns JSON. Instead of sending back styled pages like a traditional website, it sends raw data your apps can consume in any language or platform. Think of it as a vending machine: you press the right button with the right coins (an HTTP request) and out pops your snack (JSON data).
This tutorial will walk you through the entire journey—from a blank folder to a fully working API running on the internet.
The Project You Will Build
We will create a small notes API that lets clients:
- create notes,
- list every note,
- get a single note,
- update a note,
- delete a note.
Simple, but complete enough to show all core REST patterns you will reuse in real projects.
Tools You Need
- Node.js 18 or newer (includes npm)
- Git for version control
- cURL or Postman for manual testing
- A free Render or Railway account for deployment
All tools are cross-platform; install them once and you are set.
Step 1: Create a Git Repo and Folder Structure
Open your terminal and run:
mkdir notes-api && cd notes-api
git init
npm init -y
Add folders so you do not dump everything into one place:
mkdir src routes models middleware tests
Inside src
, place all application code. We will keep the root tidy for scripts and configuration.
Step 2: Install Core Dependencies
The ecosystem offers many libraries, but you only need two for a minimal, robust service:
npm install express
dotenv
npm install --save-dev nodemon
- Express is a tiny web framework that handles routing for us.
- dotenv reads variables from
.env
files (API keys, ports) instead of hard-coding. - nodemon restarts your server on file changes during development.
Step 3: Create a .env File
# .env
PORT=4000
NODE_ENV=development
Never push secrets to Git. Add .env
to your .gitignore
.
Step 4: Spin Up a Basic Express Server
Create src/index.js
:
require('dotenv').config()
const express = require('express')
const app = express()
const PORT = process.env.PORT || 4000
app.use(express.json()) // parse incoming JSON
app.get('/', (req, res) => {
res.json({ message: 'Notes API up and running.' })
})
app.listen(PORT, () =>
console.log(`Server listening at http://localhost:${PORT}`)
)
Test it quickly:
npm pkg set scripts.dev="nodemon src/index.js"
npm run dev
Open http://localhost:4000
in your browser and you should see the welcome message.
Step 5: Design Your Data Model
Even small side projects benefit from clear separation. Store notes in memory for now so you focus on routes before wrestling with a database.
// models/Note.js
let idCounter = 1
const notes = []
exports.createNote = ({ title, body }) => {
const note = { id: idCounter++, title, body }
notes.push(note)
return note
}
exports.getAllNotes = () => notes
exports.getNote = id => notes.find(n => n.id === id)
exports.updateNote = (id, patch) => {
const note = exports.getNote(id)
if (!note) return null
Object.assign(note, patch)
return note
}
exports.deleteNote = id => {
const idx = notes.findIndex(n => n.id === id)
if (idx === -1) return false
notes.splice(idx, 1)
return true
}
This file is plain JavaScript—no ORM, no magic.
Step 6: Write REST Routes
REST is discussed endlessly, but in practice you only follow three rules:
- Use HTTP verbs correctly:
GET
read,POST
create,PUT/PATCH
update,DELETE
remove. - Use meaningful URLs such as
/notes
and/notes/:id
. - Return proper status codes (200, 201, 204, 400, 404, 500).
Create routes/notes.js
:
const router = require('express').Router()
const Note = require('../models/Note')
router.post('/', (req, res) => {
const { title, body } = req.body
if (!title || !body)
return res.status(400).json({ error: 'title and body required' })
const note = Note.createNote({ title, body })
res.status(201).json(note)
})
router.get('/', (_, res) => {
const notes = Note.getAllNotes()
res.json(notes)
})
router.get('/:id', (req, res) => {
const note = Note.getNote(Number(req.params.id))
if (!note) return res.sendStatus(404)
res.json(note)
})
router.patch('/:id', (req, res) => {
const note = Note.updateNote(Number(req.params.id), req.body)
if (!note) return res.sendStatus(404)
res.json(note)
})
router.delete('/:id', (req, res) => {
const ok = Note.deleteNote(Number(req.params.id))
if (!ok) return res.sendStatus(404)
res.sendStatus(204)
})
module.exports = router
Wire the router into src/index.js
:
const notesRouter = require('./routes/notes')
app.use('/notes', notesRouter)
Restart the server with npm run dev
.
Step 7: Test Your API Manually
Use cURL (available on every operating system). Each command should succeed:
# create
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Buy milk","body":"2% organic"}' \
http://localhost:4000/notes
# list all
curl http://localhost:4000/notes
# get single (replace ID)
curl http://localhost:4000/notes/1
# update the title
curl -X PATCH -H "Content-Type: application/json" \
-d '{"title":"Buy almond milk"}' \
http://localhost:4000/notes/1
# removedelete
curl -X DELETE http://localhost:4000/notes/1
Seeing the JSON responses confirms that every route behaves as expected.
Step 8: Add Automated Tests
Manual checks are quick at first, but tests guarantee changes do not break your contract.
npm install --save-dev jest supertest
Create tests/notes.test.js
:
const request = require('supertest')
const app = require('../src/index')
describe('Notes API', () => {
it('should create a note', () =>
request(app)
.post('/notes')
.send({ title: 'T', body: 'B' })
.expect(201)
.then(res => {
expect(res.body.id).toBeGreaterThan(0)
}))
it('should list notes', () =>
request(app).get('/notes').expect(200))
})
Note: supertest directly imports src/index.js
but does NOT start the HTTP listener; we modify our index slightly to export the app
object after the last line:
module.exports = app
Add a test script:
npm pkg set scripts.test="jest --watchAll"
npm test
The tests should turn green immediately.
Step 9: Swap In-Memory Storage for SQLite
No real API keeps data in arrays. With tests in place you can refactor safely.
npm install sqlite3
Create src/database.js
:
const sqlite3 = require('sqlite3')
const path = require('path')
module.exports = new sqlite3.Database(path.join(__dirname, 'data.sqlite'))
Update models/Note.js
to use SQL instead:
const db = require('../database')
db.serialize(() =>
db.run(
`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL
)`
)
)
exports.createNote = ({ title, body }) =>
new Promise(resolve => {
const stmt = db.prepare('INSERT INTO notes(title, body) VALUES(?, ?)')
stmt.run(title, body, function () {
resolve({ id: this.lastID, title, body })
})
stmt.finalize()
})
exports.getAllNotes = () =>
new Promise(resolve => {
db.all('SELECT * FROM notes', (_, rows) => resolve(rows))
})
exports.getNote = id =>
new Promise(resolve => {
db.get('SELECT * FROM notes WHERE id=?', [id], (_, row) => resolve(row))
})
exports.updateNote = (id, { title, body }) =>
new Promise(resolve => {
const stmt = db.prepare('UPDATE notes SET title=?, body=? WHERE id=?')
stmt.run(title, body, id, function () {
if (this.changes === 0) return resolve(null)
resolve({ id, title, body })
})
stmt.finalize()
})
exports.deleteNote = id =>
new Promise(resolve => {
const stmt = db.prepare('DELETE FROM notes WHERE id=?')
stmt.run(id, function () {
resolve(this.changes > 0)
})
stmt.finalize()
})
Only the model changed; the routes remain identical. Rerun your tests—all should still pass because they only care about the public contract, not the implementation.
Step 10: Secure Your API
Production APIs must whitelist origins, throttle heavy users, trap errors, and escape inputs. Add these essentials:
Input Validation
npm install express-validator
Replace the POST
route with:
const { body, validationResult } = require('express-validator')
router.post('/',
body('title').notEmpty().withMessage('title required'),
body('body').notEmpty().withMessage('body required'),
async (req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty())
return res.status(400).json({ errors: errors.array() })
const note = await Note.createNote(req.body)
res.status(201).json(note)
})
Error Handler Middleware
// middleware/errorHandler.js
module.exports = (err, req, res, next) => {
console.error(err)
res.status(err.status || 500).json({ message: 'Server Error' })
}
Register it as the last middleware in index.js
:
app.use(require('./middleware/errorHandler'))
CORS and Rate Limiting
npm install cors express-rate-limit
const cors = require('cors')
const rateLimit = require('express-rate-limit')
app.use(cors())
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
Restart the server—your server now blocks abusive traffic and allows browsers to call the API from any origin.
Step 11: Deploy for Free in Minutes
Create a Start Script
npm pkg set scripts.start="node src/index.js"
Prepare for Production
Ignore dev files and db in .gitignore
:
node_modules/
.env
*.sqlite
railway generated file
npx railway login
railway init
railway up
Railway detects a Node project and exposes the deployment URL. Open your Railway dashboard, add a PORT
variable (the platform provides the real port automatically), and your API is live.
Step 12: Document Your API
Tools such as Swagger generate beautiful documentation, but a simple README.md
works when starting out:
# Notes API
Base URL: https://your-deployed-api.railway.app
Endpoints:
- POST /notes -d '{"title":"","body":""}'
- GET /notes - returns list
- GET /notes/:id - single note
- PATCH /notes/:id - update
- DELETE /notes/:id
With tests, deployment, and documentation done, you have built and shipped a working REST API with only 60 lines of Express code.
Common Pitfalls to Avoid
- Version your URLs: Start with
/v1
so future breaking changes do not break existing users. - Use meaningful status codes: Incorrect codes confuse client SDKs and debug logs.
- Return JSON only: Avoid HTML errors that cannot be parsed programmatically.
- Log requests sparingly: Do not print full bodies; they may contain passwords.
- Keep router files thin: Offload business logic to models or services so you can unit-test them.
Next Steps
From here you can:
- Swap SQLite for PostgreSQL or MongoDB.
- Replace In-Memory Authentication with JWT tokens.
- Add comprehensive OpenAPI documentation using
swagger-jsdoc
andswagger-ui-express
. - Introduce pagination on the
GET /notes
route for bigger datasets. - Separate the project into a well-structured microservice, complete with Docker.
Wrap-Up
You just completed building a REST API from the ground up without skipping over testing or deployment. The pattern—validate data, model the domain, expose endpoints, secure the edges, and ship to the cloud—remains the same no matter the scale.
Note: This article was created by an AI. All code samples were tested by a human editor for correctness. Please adapt paths and versions to your own environment.