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 as get, post, put, or delete. These correspond to standard CRUD operations.
  • PATH: The URL structure on the server, such as /users or /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:

  1. Route parameters (:id) are part of the URL path itself. Use these to identify a specific resource.
  1. 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:

ScenarioApproachExample
Fetch a single resource by IDRoute parameterGET /users/:id
Filter a collectionQuery stringGET /users?role=admin
Paginate resultsQuery stringGET /posts?page=2&limit=20
Nested resourceRoute parameterGET /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 OK if the user exists
  • It will respond with 404 Not Found if 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:

  1. App-level middleware runs for every request across the entire app:
  1. 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:

Note: Middleware runs in the order it is registered. If you register app.use('/account', protectedRouter) before your auth middleware, the auth check is skipped entirely. Order matters. Incorrect registration order is one of the most common sources of security bugs in Express apps.

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.

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.

ScenarioRecommended approach
Single-file prototypeapp.get/post() directly on app
Multi-resource APIexpress.Router() per resource
Auth-protected routesRouter-level authMiddleware
Shared middleware (logging, JSON parsing)App-level app.use()
404 handlingapp.use('*', ...) registered last
Async route handlersWrap 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.