Quick Answer

Cannot GET / is Express's default 404 response, sent when a request reached your server and no route matched it. The server is running correctly, so stop checking the port. The usual causes are a missing express.static for serving index.html, a route registered with a different method than the browser used, a router mounted at a prefix that gets doubled in the route path, and routes registered after a catch-all handler.

It means your server is working

When no route matches and no middleware sends a response, Express falls through to a final handler that returns a 404 with a plain HTML body reading Cannot GET /whatever. Reaching that message proves several things went right: the process is running, it is listening on the port you connected to, the request was parsed, and the router ran to completion. Nothing crashed.

Compare it with the alternatives. If nothing were listening, the browser would show a connection refused page and never display any message from your app. If your code threw during startup, the terminal would show a stack trace and the process would exit. If a handler matched but never responded, the browser would spin indefinitely instead of showing anything. Each of those symptoms points somewhere different, and confusing them is why people spend an hour on app.listen when the routing table is the problem.

const express = require('express');
const app = express();

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);
// GET /        -> Cannot GET /
// GET /health  -> ok

Read the path in the message rather than skimming past it, because Express echoes the URL it actually received. Cannot GET /api/users and Cannot GET /apiusers look similar at a glance and mean very different things. Behind a reverse proxy the path your app sees may differ from the one you typed, so logging req.method and req.originalUrl in a top-level middleware is the fastest way to see the truth.

One knock-on effect: the body is HTML, so a fetch that calls .json() on it fails with an unexpected token error instead of a clean 404.

Method mismatches and near-miss paths

Typing a URL into the browser address bar always sends a GET. If the route is app.post('/login', ...), you will get Cannot GET /login no matter how correct the handler is. Test POST routes with curl, Postman or a REST client extension, not the address bar:

curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"riya@example.com","password":"secret"}'

Path shape matters more than people expect. A route with a parameter matches exactly one segment: /students/:id matches /students/42 but not /students and not /students/42/fees. If you want both the collection and a single item, register both routes. Query strings are not part of the path, so /search?city=Pune is matched by app.get('/search'); you read the value from req.query.city.

Two Express defaults are worth memorising because they invert the assumptions you bring from file systems. Routing is case-insensitive by default, so /Students matches app.get('/students') unless someone enabled app.set('case sensitive routing', true). And strict routing is off by default, so /students and /students/ both match the same route. That means case and trailing slashes are usually not your problem in Express, which is the opposite of how imports behave on Linux.

The exception is when a project has switched those settings on. With app.set('strict routing', true), a link written as /students/ stops matching a route registered as /students, and only some of your links break. If trailing slashes are behaving inconsistently, check the app settings before rewriting any routes.

Serving index.html needs static middleware

Express does not serve files from disk unless you tell it to. A brand new app with API routes only and no express.static will always answer Cannot GET / at the root, because there is no handler for / and no directory being served. Adding the middleware fixes it in one line, since express.static serves index.html for a directory request by default.

The trap is the path you pass. express.static('public') is resolved against the process working directory, not the file the code lives in. Starting the server with node server.js from the project root works; starting it with node src/server.js from somewhere else silently serves nothing, because ./public no longer exists relative to where you ran the command. Anchor it to the file instead:

const path = require('node:path');
app.use(express.static(path.join(__dirname, 'public')));

In an ES module there is no __dirname, so rebuild it first:

import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, 'public')));

Three more things to check. The file must actually be named index.html in lowercase, because on a Linux server Index.html will not be found. The static middleware must be registered before any catch-all handler, since the first matching handler wins. And if you pass { index: false }, directory requests stop serving index files entirely, which reintroduces the 404 at the root while individual asset URLs keep working. That combination is confusing enough that it is worth checking the options object whenever assets load but the home page does not.

Routers and the doubled prefix

Splitting routes into an express.Router is good practice and introduces one very common mistake. When you mount a router at a prefix, Express strips that prefix before the router sees the request. Paths inside the router are therefore relative to the mount point, so repeating the prefix produces /students/students.

// routes/students.js
const express = require('express');
const router = express.Router();

router.get('/students', listStudents);  // wrong -> /students/students
router.get('/', listStudents);          // right -> /students
router.get('/:id', getStudent);         // right -> /students/42

module.exports = router;
// server.js
app.use('/students', require('./routes/students'));

Inside a router, req.url shows the path with the mount prefix removed, req.baseUrl holds the prefix, and req.originalUrl holds the full path the client requested. Logging all three inside a router settles most "where did my URL go" arguments immediately.

Export mistakes cause a related failure. Forgetting module.exports = router gives Express an empty object and it throws app.use() requires a middleware function at startup rather than 404ing later. Writing export default router in an ES module and then loading it with require from CommonJS hands you { default: router }, which fails the same way. Pick one module system per project and stay in it.

Finally, watch the order of the app.use calls themselves. Mounting a router after a generic 404 handler means the 404 handler answers first and the router is never consulted, so every route inside it appears to be missing at once. If an entire group of routes vanished together, look at where that router is mounted relative to your other middleware rather than at the routes themselves.

Registration order decides everything

Express walks its stack in the order things were registered and stops at the first handler that sends a response. Every routing bug that is not a typo is an ordering bug.

The rule that follows is: specific before generic. A route with a literal segment must be registered before a route with a parameter that could swallow it, otherwise the parameter wins.

app.get('/students/new', showForm);   // must come first
app.get('/students/:id', getStudent); // otherwise :id captures "new"

A catch-all placed too early has the same effect on a larger scale. A 404 handler registered near the top of the file intercepts everything below it, and a single-page-app fallback registered before your API routes turns every API call into a page of HTML. Both belong at the very bottom, after static files and after all routes.

Wildcard syntax changed between major Express versions, which trips people upgrading an old project. Express 4 accepts app.get('*', handler). Express 5 uses a newer path matching library that rejects a bare '*' and expects a named wildcard such as '/*splat', or a regular expression. If an upgrade produced a startup error mentioning a missing parameter name, this is why.

Close the file with a real 404 and a real error handler, so unmatched requests return JSON that your frontend can parse:

app.use((req, res) => {
  res.status(404).json({ error: 'Not found', path: req.originalUrl });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Server error' });
});

One last symptom to distinguish. A middleware that never calls next() and never responds does not produce Cannot GET; the request simply hangs until the browser times out. If the page spins forever rather than showing a message, look for a missing next(), not a missing route.

Frequently Asked Questions

Why does my API route return Cannot GET but my HTML page loads fine? The static middleware is serving your page and the API route is either registered with a different method, mounted at a different prefix than you think, or placed after a catch-all that answers first. Log req.method and req.originalUrl in a middleware at the top of the file, then compare that output with the exact route string you registered.
Does route order really matter in Express? Yes, completely. Express evaluates its middleware and route stack in registration order and stops at the first handler that sends a response. That means a parameterised route registered before a literal one will capture the literal path, and a catch-all near the top makes everything below it unreachable. Specific routes first, fallbacks last.
Why does my fetch call fail with a JSON syntax error instead of showing the 404? Express's default 404 body is HTML, so calling response.json() on it tries to parse text beginning with a less-than sign and throws a SyntaxError. Check response.ok before parsing, and add your own 404 handler that returns JSON so that error responses have the same content type as successful ones.
Is Cannot GET / different from a connection refused error? Very different. Cannot GET means the request reached your Express app and no route matched, so the server is healthy and the routing table is the problem. Connection refused means nothing is listening on that host and port at all, so the process either failed to start, crashed, or is bound to a different port or interface than the one you are calling.
Should I turn on strict routing to handle trailing slashes? Usually not. Express treats /students and /students/ as the same route by default, which is the forgiving behaviour most applications want. Enabling strict routing makes them distinct, which means every link, redirect and client-side fetch must use the exact form. Only enable it when you have a specific reason, such as canonical URL rules enforced elsewhere in your stack.