A Complete Node.js REST API Example
The backend of a mid-sized site often starts with one simple thing: the frontend needs data and the backend returns JSON. This article builds a full CRUD blog API with Express and SQLite — no heavy framework, few dependencies, easy for small and medium sites to ship quickly. SQLite needs no separate database server, which makes it especially friendly for early-stage projects. Say you are building the backend for a content site and the editor needs an API to manage articles: list, detail, create, retitle, delete — the code below is built exactly for that.
Project setup
mkdir my-api && cd my-api
npm init -y
npm install express cors helmet better-sqlite3
helmet sets sensible security response headers, cors handles cross-origin requests, and better-sqlite3 is a synchronous SQLite driver whose API reads more naturally than async alternatives.
Reference: Express documentation https://expressjs.com/
Main server and table setup
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const Database = require('better-sqlite3');
const app = express();
const db = new Database('app.db');
app.use(helmet());
app.use(cors());
app.use(express.json());
db.exec(`CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
Keep the schema minimal — SQLite doesn't support complex ALTER operations the way MySQL does, so it's better to plan the columns up front than to migrate later.
CRUD routes
// List all articles
app.get('/api/articles', (req, res) => {
const articles = db.prepare('SELECT * FROM articles ORDER BY created_at DESC').all();
res.json(articles);
});
// Get a single article
app.get('/api/articles/:id', (req, res) => {
const article = db.prepare('SELECT * FROM articles WHERE id = ?').get(req.params.id);
if (!article) return res.status(404).json({ error: 'Article not found' });
res.json(article);
});
// Create an article
app.post('/api/articles', (req, res) => {
const { title, content } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare('INSERT INTO articles (title, content) VALUES (?, ?)').run(title, content);
res.status(201).json({ id: result.lastInsertRowid, title, content });
});
// Update an article
app.put('/api/articles/:id', (req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE articles SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Article not found' });
res.json({ message: 'Updated successfully' });
});
// Delete an article
app.delete('/api/articles/:id', (req, res) => {
const result = db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Article not found' });
res.json({ message: 'Deleted successfully' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`API server running on port ${PORT}`);
});
Project layout
Keep the project minimal: one server.js (or index.js) holds all the routes and one app.db holds the SQLite data. That's fine for small projects; split routes into a controllers directory once they pass a dozen or so. The npm init -y step generates a default package.json — set "main" to your entry file and add "start": "node server.js" under scripts so npm start works in deployment.
Testing with curl
curl -X POST http://localhost:3000/api/articles \
-H "Content-Type: application/json" \
-d '{"title":"First post","content":"hello"}'
curl http://localhost:3000/api/articles
curl -X DELETE http://localhost:3000/api/articles/1
Routes and status code conventions
| Method | Path | Purpose | Success code | Failure code |
|---|---|---|---|---|
| GET | /api/articles | List | 200 | — |
| GET | /api/articles/:id | Single item | 200 | 404 |
| POST | /api/articles | Create | 201 | 400 |
| PUT | /api/articles/:id | Update | 200 | 404 |
| DELETE | /api/articles/:id | Delete | 200 | 404 |
The status codes follow REST conventions: 201 for creation, 400 for bad input, 404 for missing resources. For deeper design guidance, see the REST API design guide.
A consistent response shape
Nothing annoys frontend integration more than every endpoint returning a different structure. Agree on one envelope for the project — success as { "code": 0, "data": ... }, failure as { "code": 4001, "message": "Bad request" }. The frontend then only checks code to handle errors uniformly instead of writing a bespoke branch per endpoint. The examples above return raw data for brevity; wrapping responses in one envelope usually pays off in real projects.
Common middleware
Express middleware makes shared logic reusable. Beyond helmet, cors, and express.json() already used in the example, common additions include morgan for request logging, express-rate-limit for rate limiting, and express-validator for body validation. Order matters too — middleware registered before the routes runs first, so auth and rate limiting usually go at the very front.
A catch-all error handler
Production needs a catch-all error-handling middleware — otherwise Express returns an HTML error page when a route throws, which is awkward for the frontend to parse. Register a dedicated error middleware after all routes that responds with JSON:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
Combined with the consistent response shape above, the frontend can show a friendly message whenever it gets a non-zero code.
Paginating the list endpoint
The current GET /api/articles returns everything at once; as articles grow the payload gets heavy. Adding pagination is a common follow-up: read page and pageSize from the query string, query with LIMIT ? OFFSET ?, and return a total field so the frontend can render page numbers. The logic follows the same pattern as the CRUD routes above and is quick to add.
FAQ
Why better-sqlite3 instead of sqlite3? better-sqlite3 has a synchronous API — no callbacks or Promises, reads like plain code, and errors are easier to trace. The trade-off is that it executes synchronously on the Node main thread, so it fits small-to-medium traffic. POST returns 400 but the frontend didn't send content? The route only validates title; content is intentionally optional. If both are required, change the check to if (!title || !content). Port already in use? Override with PORT=4000 node server.js or manage the port through a .env file. JSON body too large? express.json() defaults to a 100kb limit; adjust with express.json({ limit: '2mb' }). How do I debug 500s after launch? Check PM2 or systemd logs first, then the SQLite file permissions; logging inside the catch-all error middleware helps you pinpoint issues fast.
What to add before going live
The version above is the minimum viable API. Before production, plan on these additions:
- Input validation. Right now only a non-empty title is checked; add a validation library to constrain length and format so bad data never reaches the database.
- Centralized error handling. Move 404/500 handling into middleware so every error returns a consistent JSON shape — see API error handling.
- Authentication. If the API accepts writes, add login and authorization first; the API security and JWT article covers this.
- Process management. Keep the service alive with PM2 and enable startup on boot so the API survives a server restart.
- Secret handling. Manage the port, database path, and other parameters via environment variables — see environment variables and secrets management.
For the full Express project walkthrough, read the Node.js Express guide; for more complex integrations, the backend integration category has more material.
5. Test
curl http://localhost:3000/api/articles
curl -X POST -H "Content-Type: application/json" -d '{"title":"Hello","content":"World"}' http://localhost:3000/api/articles
Features
- ✅ Full CRUD (Create, Read, Update, Delete)
- ✅ SQLite database (zero config)
- ✅ Input validation and error handling
- ✅ Security headers (helmet)
- ✅ CORS support
- ✅ Prepared statements (SQL injection prevention)