Episode 3 — NodeJS MongoDB Backend Architecture / 3.4 — Express JS

3.4.d — Query Parameters and URL Parameters

In one sentence: Express gives you two ways to pass data through URLs — route parameters (:id in the path) for identifying specific resources and query parameters (?key=value) for filtering, sorting, and pagination — and knowing when to use each is a fundamental API design skill.

Navigation: <- 3.4.c — Returning Responses | 3.4.e — HTTP Methods and Request Body ->


1. Route parameters: /users/:id

Route parameters are named segments in the URL path, prefixed with :. Express extracts them into req.params:

// Define a route with a parameter
app.get('/users/:id', (req, res) => {
  console.log(req.params);      // { id: '42' }
  console.log(req.params.id);   // '42' (always a string!)

  res.json({ userId: req.params.id });
});

How it works

Route pattern:  /users/:id
Actual request: GET /users/42

Express matches /users/42 against /users/:id
  → req.params = { id: '42' }

Real-world examples

// Get a single user by ID
app.get('/api/users/:id', (req, res) => {
  const userId = parseInt(req.params.id); // Convert string to number
  const user = users.find(u => u.id === userId);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});

// Get a product by slug
app.get('/products/:slug', (req, res) => {
  const product = products.find(p => p.slug === req.params.slug);
  // /products/blue-sneakers → req.params.slug = 'blue-sneakers'
  res.json(product);
});

// Get a user's profile image
app.get('/users/:username/avatar', (req, res) => {
  // /users/alice/avatar → req.params.username = 'alice'
  const avatarPath = getAvatarPath(req.params.username);
  res.sendFile(avatarPath);
});

2. Multiple parameters: /users/:userId/posts/:postId

Routes can have multiple named parameters:

app.get('/users/:userId/posts/:postId', (req, res) => {
  console.log(req.params);
  // GET /users/5/posts/42
  // → { userId: '5', postId: '42' }

  const { userId, postId } = req.params;
  const post = getPostByUser(parseInt(userId), parseInt(postId));

  if (!post) {
    return res.status(404).json({ error: 'Post not found' });
  }

  res.json(post);
});

// Nested resources pattern
app.get('/teams/:teamId/members/:memberId', (req, res) => {
  const { teamId, memberId } = req.params;
  // /teams/10/members/3 → { teamId: '10', memberId: '3' }
  res.json({ teamId, memberId });
});

// Three levels deep
app.get('/orgs/:orgId/teams/:teamId/members/:memberId', (req, res) => {
  const { orgId, teamId, memberId } = req.params;
  // /orgs/1/teams/5/members/42
  // → { orgId: '1', teamId: '5', memberId: '42' }
  res.json({ orgId, teamId, memberId });
});

Design rule: Avoid nesting deeper than 2-3 levels. Deep nesting makes URLs unwieldy:

BAD:  /orgs/1/departments/5/teams/3/members/42/posts/99/comments/7
GOOD: /comments/7  (with query: ?postId=99)

3. Optional parameters with ?

Append ? to make a route parameter optional:

// :format is optional
app.get('/api/report/:format?', (req, res) => {
  const format = req.params.format || 'json'; // Default to 'json'

  // GET /api/report       → format = 'json' (default)
  // GET /api/report/csv   → format = 'csv'
  // GET /api/report/pdf   → format = 'pdf'

  if (format === 'json') {
    return res.json({ data: 'report data' });
  }
  if (format === 'csv') {
    res.set('Content-Type', 'text/csv');
    return res.send('col1,col2\nval1,val2');
  }
  res.status(400).json({ error: `Unsupported format: ${format}` });
});

// Optional ID — list all or get one
app.get('/api/articles/:id?', (req, res) => {
  if (req.params.id) {
    // GET /api/articles/5 → single article
    const article = articles.find(a => a.id === parseInt(req.params.id));
    return res.json(article);
  }
  // GET /api/articles → all articles
  res.json(articles);
});

Note: Optional params only work for the last segment. You cannot have /users/:id?/posts — Express would not know where :id ends.


4. Query parameters: ?key=value

Query parameters come after the ? in the URL. Express auto-parses them into req.query:

app.get('/search', (req, res) => {
  console.log(req.query);
  // GET /search?q=express&page=2&limit=10
  // → { q: 'express', page: '2', limit: '10' }

  const { q, page, limit } = req.query;
  // All values are STRINGS — convert as needed
  res.json({
    searchTerm: q,
    page: parseInt(page) || 1,
    limit: parseInt(limit) || 10,
  });
});

Multiple values for the same key

// GET /filter?color=red&color=blue
app.get('/filter', (req, res) => {
  console.log(req.query.color);
  // → ['red', 'blue'] (Express auto-creates an array)
  res.json({ colors: req.query.color });
});

Nested query parameters

Express (with default query parser) supports nested objects:

// GET /search?filter[category]=electronics&filter[minPrice]=100
app.get('/search', (req, res) => {
  console.log(req.query.filter);
  // → { category: 'electronics', minPrice: '100' }
  res.json({ filter: req.query.filter });
});

Common query parameter patterns

// Pagination
// GET /api/posts?page=2&limit=20
app.get('/api/posts', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 10, 100); // Cap at 100
  const offset = (page - 1) * limit;

  const results = posts.slice(offset, offset + limit);
  res.json({ data: results, page, limit, total: posts.length });
});

// Sorting
// GET /api/products?sort=price&order=desc
app.get('/api/products', (req, res) => {
  const { sort = 'createdAt', order = 'asc' } = req.query;
  const sorted = [...products].sort((a, b) => {
    return order === 'desc' ? b[sort] - a[sort] : a[sort] - b[sort];
  });
  res.json(sorted);
});

// Filtering
// GET /api/products?category=electronics&minPrice=50&maxPrice=200
app.get('/api/products', (req, res) => {
  let filtered = [...products];
  if (req.query.category) {
    filtered = filtered.filter(p => p.category === req.query.category);
  }
  if (req.query.minPrice) {
    filtered = filtered.filter(p => p.price >= parseFloat(req.query.minPrice));
  }
  if (req.query.maxPrice) {
    filtered = filtered.filter(p => p.price <= parseFloat(req.query.maxPrice));
  }
  res.json(filtered);
});

// Search
// GET /api/users?search=alice&role=admin
app.get('/api/users', (req, res) => {
  let results = [...users];
  if (req.query.search) {
    const term = req.query.search.toLowerCase();
    results = results.filter(u =>
      u.name.toLowerCase().includes(term) ||
      u.email.toLowerCase().includes(term)
    );
  }
  if (req.query.role) {
    results = results.filter(u => u.role === req.query.role);
  }
  res.json(results);
});

5. Query vs params — when to use each

AspectRoute Params (:id)Query Params (?key=val)
PurposeIdentify a specific resourceFilter, sort, paginate a collection
Required?Yes (unless optional ?)Always optional
WhereIn the URL pathAfter ? in the URL
Accessreq.params.idreq.query.key
Data typeAlways stringAlways string
Examples/users/42, /posts/hello-world?page=2&limit=10&sort=name

Decision guide with examples

URLParams or Query?Why
/users/42Param (:id)Identifying one user
/users?role=adminQueryFiltering a list
/posts/slug-titleParam (:slug)Identifying one post
/posts?page=2&limit=10QueryPaginating a list
/products/electronicsParam (:category)Scoping to a category
/products?sort=price&order=ascQuerySorting a list
/search?q=express+tutorialQuerySearch is a filter

Combined usage

// Params to identify, query to filter within that scope
// GET /teams/5/members?role=admin&page=1
app.get('/teams/:teamId/members', (req, res) => {
  const { teamId } = req.params;       // Which team
  const { role, page } = req.query;     // Filter + paginate members

  let members = getMembersByTeam(parseInt(teamId));

  if (role) {
    members = members.filter(m => m.role === role);
  }

  const p = parseInt(page) || 1;
  const paginated = members.slice((p - 1) * 10, p * 10);

  res.json({ teamId, members: paginated, page: p });
});

6. req.params — always strings, need type conversion

Every value in req.params and req.query is a string. This is a common source of bugs:

app.get('/api/users/:id', (req, res) => {
  // WRONG — comparing string to number
  const user = users.find(u => u.id === req.params.id);
  // If user.id is a number (1, 2, 3), this NEVER matches!

  // CORRECT — convert first
  const id = parseInt(req.params.id, 10);
  const user = users.find(u => u.id === id);
});

// Same for query params
app.get('/api/products', (req, res) => {
  // WRONG
  const minPrice = req.query.minPrice; // '50' (string)
  products.filter(p => p.price > minPrice); // string comparison!

  // CORRECT
  const minPrice = parseFloat(req.query.minPrice);
  products.filter(p => p.price > minPrice); // numeric comparison
});

Safe parsing helpers

function parseIntSafe(value, defaultValue = 0) {
  const parsed = parseInt(value, 10);
  return Number.isNaN(parsed) ? defaultValue : parsed;
}

function parseFloatSafe(value, defaultValue = 0) {
  const parsed = parseFloat(value);
  return Number.isNaN(parsed) ? defaultValue : parsed;
}

// Usage
app.get('/api/users/:id', (req, res) => {
  const id = parseIntSafe(req.params.id, -1);
  if (id < 0) {
    return res.status(400).json({ error: 'Invalid ID' });
  }
  // ...
});

7. Parsing and validating params

Always validate parameters before using them:

app.get('/api/users/:id', (req, res) => {
  // Step 1: Parse
  const id = parseInt(req.params.id, 10);

  // Step 2: Validate
  if (Number.isNaN(id) || id <= 0) {
    return res.status(400).json({
      error: 'Invalid user ID',
      details: 'ID must be a positive integer',
    });
  }

  // Step 3: Use
  const user = users.find(u => u.id === id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});

// Validate query parameters
app.get('/api/products', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  const sort = req.query.sort || 'createdAt';
  const order = req.query.order || 'desc';

  // Validate sort field (prevent arbitrary field access)
  const allowedSortFields = ['name', 'price', 'createdAt', 'rating'];
  if (!allowedSortFields.includes(sort)) {
    return res.status(400).json({
      error: `Invalid sort field. Allowed: ${allowedSortFields.join(', ')}`,
    });
  }

  // Validate order
  if (!['asc', 'desc'].includes(order)) {
    return res.status(400).json({ error: 'Order must be "asc" or "desc"' });
  }

  // Validate limit (prevent abuse)
  if (limit < 1 || limit > 100) {
    return res.status(400).json({ error: 'Limit must be between 1 and 100' });
  }

  // ... fetch and return data ...
});

8. Wildcard routes: /api/*

Express supports wildcard (*) routing for catch-all patterns:

// Catch all routes under /api/
app.get('/api/*', (req, res) => {
  // /api/anything/here/at/all
  console.log(req.params[0]); // 'anything/here/at/all'
  res.status(404).json({ error: 'API route not found' });
});

// Serve SPA — catch all non-API routes
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// Proxy pattern — forward all /proxy/* requests
app.all('/proxy/*', (req, res) => {
  const targetPath = req.params[0];
  // Forward to external service...
  res.json({ proxied: targetPath });
});

Order matters: Place specific routes before wildcards:

// CORRECT order
app.get('/api/users', handler1);        // Specific — matched first
app.get('/api/users/:id', handler2);    // Specific with param
app.get('/api/*', notFoundHandler);     // Wildcard catch-all — last

// WRONG order — wildcard catches everything
app.get('/api/*', notFoundHandler);     // This matches ALL /api/ routes!
app.get('/api/users', handler1);        // Never reached

9. Route parameter constraints and patterns

Express 4 uses path-to-regexp for route matching. You can use regex for constraints:

// Only match numeric IDs
app.get('/users/:id(\\d+)', (req, res) => {
  // /users/42    → matches (id = '42')
  // /users/abc   → does NOT match (falls through)
  res.json({ id: parseInt(req.params.id) });
});

// Match specific formats
app.get('/files/:name.:ext', (req, res) => {
  // /files/report.pdf → { name: 'report', ext: 'pdf' }
  // /files/data.csv   → { name: 'data', ext: 'csv' }
  const { name, ext } = req.params;
  res.json({ fileName: name, extension: ext });
});

// Match date-like patterns
app.get('/archive/:year(\\d{4})/:month(\\d{2})', (req, res) => {
  // /archive/2024/03 → { year: '2024', month: '03' }
  // /archive/abc/01  → does NOT match
  res.json({ year: req.params.year, month: req.params.month });
});

Note: In Express 5 (upcoming), path-to-regexp v8 is stricter about syntax. The patterns above work in Express 4.


10. Real examples

User profile by ID

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
  { id: 3, name: 'Charlie', email: 'charlie@example.com', role: 'user' },
];

app.get('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (Number.isNaN(id)) {
    return res.status(400).json({ error: 'ID must be a number' });
  }

  const user = users.find(u => u.id === id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json({ data: user });
});

Search with filters

const products = [
  { id: 1, name: 'Laptop', category: 'electronics', price: 999 },
  { id: 2, name: 'Shirt', category: 'clothing', price: 29 },
  { id: 3, name: 'Phone', category: 'electronics', price: 699 },
  { id: 4, name: 'Shoes', category: 'clothing', price: 89 },
];

// GET /api/products?search=lap&category=electronics&minPrice=500&maxPrice=1200
app.get('/api/products', (req, res) => {
  let results = [...products];

  // Text search
  if (req.query.search) {
    const term = req.query.search.toLowerCase();
    results = results.filter(p => p.name.toLowerCase().includes(term));
  }

  // Category filter
  if (req.query.category) {
    results = results.filter(p => p.category === req.query.category);
  }

  // Price range
  if (req.query.minPrice) {
    results = results.filter(p => p.price >= parseFloat(req.query.minPrice));
  }
  if (req.query.maxPrice) {
    results = results.filter(p => p.price <= parseFloat(req.query.maxPrice));
  }

  res.json({ data: results, count: results.length });
});

Pagination

// GET /api/posts?page=2&limit=5
app.get('/api/posts', (req, res) => {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(Math.max(1, parseInt(req.query.limit) || 10), 100);
  const startIndex = (page - 1) * limit;
  const endIndex = startIndex + limit;

  const paginatedPosts = allPosts.slice(startIndex, endIndex);
  const totalPages = Math.ceil(allPosts.length / limit);

  res.json({
    data: paginatedPosts,
    pagination: {
      currentPage: page,
      totalPages,
      totalItems: allPosts.length,
      itemsPerPage: limit,
      hasNextPage: page < totalPages,
      hasPrevPage: page > 1,
    },
  });
});

11. Key takeaways

  1. Route params (/users/:id -> req.params.id) identify specific resources.
  2. Query params (?page=2&limit=10 -> req.query.page) filter, sort, and paginate collections.
  3. Both req.params and req.query values are always strings — convert before comparison.
  4. Validate all params: check type, range, allowed values. Never trust user input.
  5. Optional params (:id?) only work for the last segment.
  6. Wildcard routes (/api/*) act as catch-alls — place them after specific routes.
  7. Combine params and query for expressive URLs: /teams/:teamId/members?role=admin.

Explain-It Challenge

Explain without notes:

  1. Given the URL /api/users/42?fields=name,email, identify what comes from req.params and what from req.query.
  2. Why are route params always strings, and what bug does this cause when comparing to database IDs?
  3. Design the URL structure for an API that lists blog posts by a specific author, with pagination and sorting. Which parts are params? Which are query?

Navigation: <- 3.4.c — Returning Responses | 3.4.e — HTTP Methods and Request Body ->