Quickstart
A working, protected products API in ten minutes.
Before you start#
You need Node.js 20.19 or newer and a MongoDB you can connect to — a local mongod, docker run -p 27017:27017 mongo, or a free Atlas cluster. By the end you will have a products API that anyone can read and only an admin can change.
Build a products API#
Create the project
mkdir shop-api && cd shop-api npm init -y && npm pkg set type=module npm install express-controller-sets express mongooseDefine a model
A normal Mongoose model — nothing library-specific.
// models/Product.js import mongoose from 'mongoose'; const productSchema = new mongoose.Schema({ name: { type: String, required: true }, price: { type: Number, required: true, min: 0 }, category: String, inStock: { type: Boolean, default: true }, }, { timestamps: true }); export default mongoose.model('Product', productSchema);Mount a router
Each option grants clients one permission. Anything you don't list stays closed.
// app.js import express from 'express'; import mongoose from 'mongoose'; import { createRouter, errorHandler } from 'express-controller-sets'; import Product from './models/Product.js'; const app = express(); app.use(express.json()); // 1. parse JSON bodies — first app.use('/api/products', createRouter({ // 2. mount the router model: Product, orderBy: '-createdAt', // default order: newest first query: ['category', 'inStock'], // may filter: ?category=chairs search: ['name'], // may search: ?s=oak filterableFields: ['price', 'category', 'inStock'], // may range/compare: price 50–200 sortableFields: ['price', 'name', 'createdAt'], // may sort: ?sort=-price allowedFields: ['name', 'price', 'category', 'inStock'], // may write these fields, nothing else })); app.use(errorHandler); // 3. error handler — always last await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/shop'); app.listen(3000, () => console.log('http://localhost:3000/api/products'));Run it
node app.jsCall it
# Create curl -X POST localhost:3000/api/products -H 'Content-Type: application/json' \ -d '{"name":"Oak chair","price":120,"category":"chairs"}' # List: filter, search, sort, paginate curl 'localhost:3000/api/products?category=chairs&s=oak&sort=-price&page=1&pageSize=10' # Read, update, delete one curl localhost:3000/api/products/<id> curl -X PATCH localhost:3000/api/products/<id> -H 'Content-Type: application/json' -d '{"price":99}' curl -X DELETE localhost:3000/api/products/<id>Lock down writes
Right now anyone can create, change and delete products — generated routes are public until you add a guard. Split the model into a public read-only router and a guarded admin router:
const catalogue = { model: Product, orderBy: '-createdAt', query: ['category', 'inStock'], search: ['name'], filterableFields: ['price', 'category', 'inStock'], sortableFields: ['price', 'name', 'createdAt'], }; // Stand-in guard for this tutorial — replace with auth.requireAuth + requireRole('admin') const adminOnly = (req, res, next) => req.get('x-api-key') === process.env.ADMIN_KEY ? next() : res.status(401).json({ success: false, error: 'Unauthorized.' }); // Refuse every method that isn't a read. const readOnly = (req, res, next) => ['GET', 'HEAD', 'OPTIONS', 'QUERY'].includes(req.method) ? next() : res.status(405).json({ success: false, error: 'Method not allowed.' }); // Public: read only. app.use('/api/products', createRouter({ ...catalogue, middlewares: [readOnly], allowedFields: [] })); // Admin: read and write, behind the guard. app.use('/api/admin/products', createRouter({ ...catalogue, middlewares: [adminOnly], allowedFields: ['name', 'price', 'category', 'inStock'], }));allowedFields: []alone does not make a router read-only. It stopsPOSTandPATCHfrom writing any field, butDELETE /:idhas no body to filter — withoutreadOnly, anyone could delete products.For real sign-in with JWTs and roles, see Authentication.
What you get back#
Every response has the same envelope, so a client always branches on success:
// GET /api/products?page=1&pageSize=10
{
"success": true,
"data": [ { "_id": "65af…", "name": "Oak chair", "price": 120, "category": "chairs", "inStock": true } ],
"pagination": { "currentPage": 1, "pageSize": 10, "totalPages": 1, "totalRecords": 1 }
}
// Any error
{ "success": false, "error": "Entry not found." }
What just happened#
createRouterbuilt six routes from one model. Options didn't add features — they granted permissions. A field not named inquerycan't be filtered; one not inallowedFieldscan't be written. That's the deny-by-default model.express.json()goes first, so request bodies are parsed;errorHandlergoes last, so unexpected failures become a clean 500 instead of a stack trace.- Access control is yours: the library builds routes,
middlewaresdecides who may call them.