Real REST APIs rarely work with fixed, hard-coded paths alone. Users request specific resources by ID, filter lists by category, search by keyword, and page through results โ all of which require dynamic values in the URL. Express provides two mechanisms for this: route parameters (dynamic path segments declared with a colon) and query strings (key-value pairs after the question mark). Mastering both, knowing which one to use for which purpose, and validating the values you receive before using them are core Express API skills covered in this lesson.
Route Parameters vs Query Strings โ When to Use Each
| Route Parameters | Query Strings | |
|---|---|---|
| Syntax | /api/posts/:id |
/api/posts?page=2&limit=10 |
| Read via | req.params.id |
req.query.page |
| Required? | Yes โ path won’t match without it | No โ all query params are optional by default |
| Use for | Identifying a specific resource | Filtering, sorting, searching, pagination |
| REST examples | /api/posts/64a1f2b3, /api/users/jane |
/api/posts?tag=mern&sort=latest&page=3 |
/api/posts/42 gives you req.params.id === '42' (string), not 42 (number). Always convert to the expected type before using: parseInt(req.params.id, 10) for numeric IDs or verify it is a valid MongoDB ObjectId before querying./api/posts/:id?. The route matches both /api/posts and /api/posts/123 with a single handler. This is useful for list/detail routes you want to keep in one place, though for clarity most APIs use separate route definitions./api/posts/../../../../etc/passwd or with a query param containing a MongoDB operator injection (?filter[$gt]=) can cause security issues. Always validate that an ID looks like a valid MongoDB ObjectId before passing it to Mongoose.Route Parameters โ req.params
// โโ Single parameter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
app.get('/api/posts/:id', async (req, res) => {
const { id } = req.params; // e.g. '64a1f2b3c8e4d5f6a7b8c9d0'
console.log(typeof id); // 'string' โ always a string
// Validate MongoDB ObjectId format before querying
if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return res.status(400).json({ message: 'Invalid post ID format' });
}
const post = await Post.findById(id);
if (!post) return res.status(404).json({ message: 'Post not found' });
res.json({ success: true, data: post });
});
// โโ Multiple parameters โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
app.get('/api/users/:userId/posts/:postId', async (req, res) => {
const { userId, postId } = req.params;
// GET /api/users/abc123/posts/xyz789
// โ userId = 'abc123', postId = 'xyz789'
const post = await Post.findOne({ _id: postId, author: userId });
if (!post) return res.status(404).json({ message: 'Post not found for this user' });
res.json({ success: true, data: post });
});
// โโ Wildcard parameter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
app.get('/api/files/*', (req, res) => {
const filePath = req.params[0]; // captures everything after /api/files/
// GET /api/files/images/2025/photo.jpg โ filePath = 'images/2025/photo.jpg'
res.json({ path: filePath });
});
Query Strings โ req.query
// GET /api/posts?tag=mern&published=true&sort=createdAt&order=desc&page=2&limit=10
app.get('/api/posts', async (req, res) => {
// All query params arrive as strings โ convert and provide defaults
const {
tag,
published,
sort = 'createdAt',
order = 'desc',
page = '1',
limit = '10',
search,
} = req.query;
// Convert types
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(100, parseInt(limit, 10) || 10); // cap at 100
const skip = (pageNum - 1) * limitNum;
const sortOrder = order === 'asc' ? 1 : -1;
// Build the MongoDB query object dynamically
const query = {};
if (tag) query.tags = tag;
if (published) query.published = published === 'true';
if (search) query.$text = { $search: search };
const posts = await Post.find(query)
.sort({ [sort]: sortOrder })
.skip(skip)
.limit(limitNum);
const total = await Post.countDocuments(query);
res.json({
success: true,
count: posts.length,
total,
page: pageNum,
pages: Math.ceil(total / limitNum),
data: posts,
});
});
Combining Parameters and Query Strings
// GET /api/users/:userId/posts?published=true&sort=createdAt
// Real-world example: get a specific user's published posts, sorted
app.get('/api/users/:userId/posts', async (req, res) => {
const { userId } = req.params; // required โ part of the path
const { published, sort = 'createdAt', page = '1', limit = '10' } = req.query;
const pageNum = parseInt(page, 10) || 1;
const limitNum = parseInt(limit, 10) || 10;
const filter = { author: userId };
if (published !== undefined) filter.published = published === 'true';
const posts = await Post.find(filter)
.sort({ [sort]: -1 })
.skip((pageNum - 1) * limitNum)
.limit(limitNum)
.populate('author', 'name avatar');
res.json({ success: true, count: posts.length, data: posts });
});
Validating ObjectId Parameters
// server/src/middleware/validateObjectId.js
// Reusable middleware to validate MongoDB ObjectId route params
const mongoose = require('mongoose');
const validateObjectId = (paramName = 'id') => (req, res, next) => {
const id = req.params[paramName];
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({
success: false,
message: `Invalid ${paramName} format โ must be a valid MongoDB ObjectId`,
});
}
next();
};
module.exports = validateObjectId;
// Usage:
const validateObjectId = require('./middleware/validateObjectId');
app.get('/api/posts/:id', validateObjectId('id'), getPostById);
app.put('/api/posts/:id', validateObjectId('id'), updatePost);
app.delete('/api/posts/:id', validateObjectId('id'), deletePost);
Common Mistakes
Mistake 1 โ Not converting string params to numbers
โ Wrong โ comparing a string ID to a numeric array index:
const post = posts.find(p => p.id === req.params.id);
// req.params.id is '1' (string), p.id is 1 (number)
// '1' === 1 is false โ post is always undefined
โ Correct โ parse the parameter to the expected type:
const id = parseInt(req.params.id, 10);
const post = posts.find(p => p.id === id); // โ
Mistake 2 โ Trusting unvalidated query string numbers
โ Wrong โ using query params directly in arithmetic without parsing:
const skip = req.query.page * req.query.limit;
// '2' * '10' = 20 in JS (coercion) โ but 'abc' * '10' = NaN
// NaN passed to .skip() throws a Mongoose error
โ Correct โ always parseInt with a fallback:
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit; // safe arithmetic โ
Mistake 3 โ Passing an invalid ObjectId to Mongoose without checking
โ Wrong โ querying MongoDB with an invalid ID causes a CastError exception:
app.get('/api/posts/:id', async (req, res) => {
const post = await Post.findById(req.params.id);
// If req.params.id is 'not-an-id' โ CastError: Cast to ObjectId failed
// โ 500 error instead of a helpful 400/404
});
โ Correct โ validate the ObjectId format before querying:
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
return res.status(400).json({ message: 'Invalid ID format' });
}
const post = await Post.findById(req.params.id);
Quick Reference
| Task | Code |
|---|---|
| Define param route | app.get('/api/posts/:id', handler) |
| Read param | req.params.id |
| Read query string | req.query.page |
| Parse number param | parseInt(req.params.id, 10) |
| Parse number query | parseInt(req.query.page, 10) || 1 |
| Default query value | const { sort = 'createdAt' } = req.query |
| Validate ObjectId | mongoose.Types.ObjectId.isValid(id) |
| Multiple params | /api/users/:userId/posts/:postId |