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 (
:idin 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
| Aspect | Route Params (:id) | Query Params (?key=val) |
|---|---|---|
| Purpose | Identify a specific resource | Filter, sort, paginate a collection |
| Required? | Yes (unless optional ?) | Always optional |
| Where | In the URL path | After ? in the URL |
| Access | req.params.id | req.query.key |
| Data type | Always string | Always string |
| Examples | /users/42, /posts/hello-world | ?page=2&limit=10&sort=name |
Decision guide with examples
| URL | Params or Query? | Why |
|---|---|---|
/users/42 | Param (:id) | Identifying one user |
/users?role=admin | Query | Filtering a list |
/posts/slug-title | Param (:slug) | Identifying one post |
/posts?page=2&limit=10 | Query | Paginating a list |
/products/electronics | Param (:category) | Scoping to a category |
/products?sort=price&order=asc | Query | Sorting a list |
/search?q=express+tutorial | Query | Search 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
- Route params (
/users/:id->req.params.id) identify specific resources. - Query params (
?page=2&limit=10->req.query.page) filter, sort, and paginate collections. - Both
req.paramsandreq.queryvalues are always strings — convert before comparison. - Validate all params: check type, range, allowed values. Never trust user input.
- Optional params (
:id?) only work for the last segment. - Wildcard routes (
/api/*) act as catch-alls — place them after specific routes. - Combine params and query for expressive URLs:
/teams/:teamId/members?role=admin.
Explain-It Challenge
Explain without notes:
- Given the URL
/api/users/42?fields=name,email, identify what comes fromreq.paramsand what fromreq.query. - Why are route params always strings, and what bug does this cause when comparing to database IDs?
- 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 ->