Most developers learn Express routing by copy-pasting app.get('/', ...) from a tutorial and calling it done. That works until the app grows. Routes pile up in a single file, middleware gets applied inconsistently, and debugging becomes a process of tracing spaghetti.
Express routing isn’t complicated, but it has structure worth understanding early. Get it right from the start, and scaling an app stays clean. Get it wrong, and you end up refactoring the entire backbone of your server to fix a structural problem that never needed to happen.
This post walks through that structure, starting from the basics and covering the patterns that keep larger apps manageable.
What routing actually means in Express
In Express, routing is the mechanism that connects an incoming HTTP request to a function that handles it. A basic route in Express follows this signature: app.METHOD(PATH, HANDLER).
app: The instance of the Express application.METHOD: The lowercase version of the HTTP request method, such asget,post,put, ordelete. These correspond to standard CRUD operations.PATH: The URL structure on the server, such as/usersor/products/42.HANDLER: A callback function that executes when the path and method match. This function receives the request object (req) and the response object (res).
In the example above, get is the method, /users is the path, and the arrow function is the handler. When a GET /users request arrives, Express executes that handler.

Understanding this flow matters because it determines where things go wrong. When a route doesn’t match, a handler throws, or middleware doesn’t call next(), it happens at a specific point in this chain. Knowing the chain makes debugging faster.
The basics: defining routes
Express provides methods for every standard HTTP verb. Each one maps directly to its HTTP equivalent:
Route parameters vs. query strings
When building routes, you’ll regularly need to pass dynamic values in the URL. Express handles this in two ways:
- Route parameters (
:id) are part of the URL path itself. Use these to identify a specific resource.
- Query strings (
?key=value) are appended after the path. Use these for optional filters, sorting, or pagination: they refine a collection rather than identify a single item.
Here’s a quick reference for when to use each:
| Scenario | Approach | Example |
|---|---|---|
| Fetch a single resource by ID | Route parameter | GET /users/:id |
| Filter a collection | Query string | GET /users?role=admin |
| Paginate results | Query string | GET /posts?page=2&limit=20 |
| Nested resource | Route parameter | GET /posts/:postId/comments |
The mental shift: routes as a contract
Before writing a handler, define what the route is supposed to do.
When you define GET /users/:id, you are making a promise to every client that sends that request:
- This endpoint will return a single user
- It will respond with
200 OKif the user exists - It will respond with
404 Not Foundif they don’t - The response shape will be consistent every time
Most routing bugs aren’t Express bugs. They’re broken contracts: endpoints that sometimes return an array, sometimes an object, and sometimes nothing at all. The fix isn’t clever code; it’s defining the expected behavior before writing the implementation.
A practical approach: before coding a handler, write a comment above it that describes the input, the expected success response, and the expected error responses. This takes 30 seconds and eliminates hours of ambiguity later.
Organizing routes with express.Router()
When an app has more than a handful of routes, keeping them all in a single index.js file creates a real problem. The file grows quickly, routes for unrelated resources sit next to each other, and applying middleware to a specific group becomes awkward.
express.Router() solves this. It creates a mini-application, a self-contained instance of Express that handles its own routes and middleware. You define routes on the router, then mount the router on a path in your main app.
A modular routing structure
Split each resource into its own router file and mount it on a dedicated path in app.js.
The following routes/users.js defines all user-related endpoints and exports the router for mounting in the main app.
routes/posts.js follows the same pattern for post-related endpoints, keeping post logic completely separate from users.
app.js imports both routers and mounts them on their respective base paths, keeping the entry point clean.
The result is clean: GET /users/42 hits usersRouter, GET /posts/10 hits postsRouter. Each router owns its own resource. Here’s how this structure looks visually:

This pattern also makes your codebase easier to test. Each router file is an independent module, so you can test it in isolation without booting your entire app.
Middleware in the routing layer
Middleware is a function that runs between the request arriving and the handler executing. It receives the req and res objects and a next function. If it calls next(), Express passes control to the next middleware or handler. If it doesn’t, the request stops there.
There are two levels at which middleware can be applied:
- App-level middleware runs for every request across the entire app:
- Router-level middleware runs only for routes registered on a specific router:
In this example, authMiddleware only runs for GET /account/profile and PUT /account/profile. Public routes on other routers are completely unaffected.
Here’s how the execution order flows when a request hits a protected route:

Chaining handlers with app.route()
If you need to handle multiple HTTP methods on the same URL path, use app.route(PATH) to reduce redundancy.
Common routing mistakes
These are the patterns that consistently cause silent failures, security gaps, and hard-to-debug behavior in Express apps.
Forgetting next() in middleware
If middleware doesn’t call next(), the request hangs. There’s no error, no response, and the client just waits until it times out. This is easy to overlook when adding a conditional early return:
Defining routes after the error handler
Express processes middleware and routes in registration order. If a route is registered after app.use(errorHandler), it will never be reached during normal request flow because Express has already walked past it. Always register all routes before the error handler.
Mixing route logic and business logic
A route handler that queries the database, formats the response, validates inputs, and sends the reply is doing too many things. When that logic breaks, you don’t know where to look.
Keep handlers thin. Route handlers should coordinate, not implement:
Using app.use('*') as an early catch-all
A wildcard catch-all registered too early will swallow legitimate routes registered afterward. Reserve it for 404 handling, and register it last.
Not handling async errors
In Express 4, unhandled promise rejections in async route handlers are not caught automatically and can crash the server. Express 5 resolves this by forwarding rejected promises to the error handler by default.
If you are on Express 4, always wrap async handlers with try/catch and call next(err):
Production best practices
These conventions apply regardless of app size and are worth building into your routing habits from day one.
- Use nouns, not verbs: Avoid creating verb-based routes. The HTTP method describes the action, while the route identifies the resource.
- Incorrect:
POST /createUser - Correct:
POST /users
- Incorrect:
- Avoid sensitive data in URL parameters: URL parameters are visible in browser history and server logs. Never place passwords, session tokens, or API keys in a URL parameter. Use the request body for sensitive data.
- Route order: Express processes routes in the order they are defined. Always place specific routes, such as
/users/active, before generic ones like/users/:id.
Reference map: routing decisions at a glance
Use this as a quick lookup when deciding how to structure a new route or where to apply middleware.
| Scenario | Recommended approach |
|---|---|
| Single-file prototype | app.get/post() directly on app |
| Multi-resource API | express.Router() per resource |
| Auth-protected routes | Router-level authMiddleware |
| Shared middleware (logging, JSON parsing) | App-level app.use() |
| 404 handling | app.use('*', ...) registered last |
| Async route handlers | Wrap in try/catch and call next(err) |
Nested resources (/posts/:id/comments) | Route params on a nested router |
Conclusion
Express routing is the skeleton of your backend. Get the structure right early, with modular routers, consistent contracts, and correctly ordered middleware, and the app stays navigable as it grows. Add routes to a single file forever, skip the structure, and you’ll eventually spend more time untangling the architecture than building features.
The most practical place to start: take your next Express feature and split its routes into a dedicated express.Router() file before writing any handlers. That single habit alone prevents most of the structural problems that slow teams down later.
If you want a structured path to reinforce these patterns with hands-on practice, follow Learn Express.js.