Route Parameters and Query Strings in Express

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
Note: All route parameters and query string values arrive as strings regardless of what the client sends. The URL /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.
Tip: Use optional route parameters by appending a question mark: /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.
Warning: Never use raw route parameters or query string values directly in database queries without validation. A request to /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

🧠 Test Yourself

A client sends GET /api/posts?page=2&limit=5. Inside your Express handler, what are the values and types of req.query.page and req.query.limit?