How to Serve Static Files in Express

A page in your Express app needs its stylesheet or image to load from a project folder. Express serves those browser-facing files from a directory on disk through request URLs.

This article explains how express.static maps a folder to URLs and walks through serving a file and checking its response.

TL;DR

Use Express’s built-in express.static middleware to map a directory of public assets to request URLs.

  • Mount it with app.use(express.static(path.join(__dirname, ‘public’))).
  • The public directory name is omitted from URLs unless you add a virtual mount prefix.
  • Keep private files outside the directory you expose.

What Are Static Files in Express?

Static files are browser-facing assets such as HTML pages, stylesheets, scripts, and images that Express serves from a filesystem directory with express.static(root), while a route can choose a single file after authorization.

Filesystem fileStatic rootRequest URL
public/index.htmlpublic/index.html
public/css/site.csspublic/css/site.css
public/images/logo.svgpublic/images/logo.svg

How to Serve Static Files in Express Step by Step

Install Express in the project, keep assets in a directory intended for public access, then mount express.static before starting the server. I ran a small Express app in this workspace and requested an HTML file, a stylesheet, and a missing file to check the returned status and content type.

Step 1: Install Express and prepare a public folder

Express 5 requires Node.js 18 or later, according to its API reference. Create a project directory, install Express there, and place browser-facing assets in a directory such as public.

npm install express

A practical layout keeps the server file beside public and groups related assets inside it.

project/
  server.cjs
  public/
    index.html
    css/
      site.css

Step 2: Mount an absolute filesystem root

Pass express.static an absolute path so the app does not depend on the directory from which the Node process starts. The official Express guide uses path.join with the application directory for this reason.

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

app.use(express.static(path.join(__dirname, 'public')));

app.listen(3000);

In CommonJS, __dirname identifies the directory containing the current module, and path.join builds the root path using the platform’s path separator. A relative express.static(‘public’) root resolves from the process working directory, which may change when a service manager or a different shell launches the app.

This sample uses CommonJS and its __dirname value. In an ECMAScript module, derive the module directory from import.meta.url with Node’s fileURLToPath and path.dirname. Pass that absolute path to express.static, and check the package type and file extension to confirm how Node interprets the server file.

If a frontend build writes deployable files to dist, configure that output directory as the root. Confirm the expected index and assets exist there before debugging their URLs. The browser requests generated files, while source components and modules may never be requested directly.

Step 3: Request a file using its public URL

app.use('/static', express.static(path.join(__dirname, 'public')));

With this mount, public/css/site.css is requested at /static/css/site.css. Use that origin-rooted URL in HTML, including pages nested under routes.

Step 4: Run the request check

The demo starts a local Express server and requests both present and missing paths. Save it as static_files_demo.cjs beside the public folder, install Express, then run the command shown below. The output checks HTTP responses instead of only confirming that the server started.

The example listens on port 0, which asks the operating system to select an available port. It reads the assigned port from server.address() before making requests to loopback. That keeps the check independent of a fixed port that another local process may already use.

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

app.use('/static', express.static(path.join(__dirname, 'public')));

const server = app.listen(0, async () => {
  const address = server.address();
  const base = `http://127.0.0.1:${address.port}`;
  try {
    for (const route of ['/static/index.html', '/static/css/site.css', '/missing.css']) {
      const response = await fetch(base + route);
      console.log(`${response.status} ${route} (${response.headers.get('content-type') || 'no content type'})`);
    }
  } finally {
    server.close();
  }
});
node static_files_demo.cjs
200 /static/index.html (text/html; charset=utf-8)
200 /static/css/site.css (text/css; charset=utf-8)
404 /missing.css (text/html; charset=utf-8)

The first two URLs returned 200 with HTML and CSS content types. The missing URL returned 404 for that path. The process exited with status 0 after printing all three responses.

Terminal output showing successful HTML and CSS requests and a missing-file 404

The terminal output shows successful assets and the missing-file response from this demo.

Why Is an Express Static File Returning 404?

A browser-level 404 means the requested URL did not return the intended asset. Use the checks below to find where the request path and file location diverge.

Use a /static prefix when root-mounted files could share paths such as /api/status with application routes.

Compare filename capitalization too. On a case-sensitive filesystem, /images/Logo.svg must match the disk path exactly.

What you seeLikely causeWhat to check
Every asset returns 404Wrong filesystem root or app start directoryUse path.join(__dirname, ‘public’) and confirm the file is there
One nested asset returns 404URL omits or adds a folder segmentMap public/css/site.css to /css/site.css, or /static/css/site.css when mounted at /static
A route response appears insteadStatic middleware did not serve the request and later middleware handled itCheck mount path and the order of app.use calls
Stylesheet request returns HTMLA fallback route answered after the asset lookup missedInspect the response status and Content-Type, then correct the stylesheet URL

By default, a missing asset can pass to later middleware, while fallthrough false sends client errors to Express error handling.

Reserve the static directory for browser-facing assets. Keep credentials, backups, and user uploads in a separate protected location.

Use multiple roots when generated assets and hand-maintained files live in separate directories. Express searches roots in mount order, so put the preferred copy first when paths overlap.

app.use(express.static(path.join(__dirname, 'public-optimized')));
app.use(express.static(path.join(__dirname, 'public')));

Browsers interpret assets using response Content-Type. CSS should return text/css, while HTML uses text/html. The local check reads this header alongside the status.

Directory requests return index.html by default. Set index to false to let directory requests continue through middleware, including nested directories without an index. Dotfiles are ignored by default, and enabling them can expose hidden files.

Express’s maxAge option uses milliseconds, while the Cache-Control max-age header uses seconds. The middleware converts the option when it writes the header. Immutable fits versioned asset URLs with a configured maxAge.

No-cache allows storage but requires validation before reuse. No-store asks caches not to store a response.

For a long cache lifetime, publish assets under URLs that change with the file contents. HTML often needs shorter freshness because it points readers to current asset URLs. The serve-static setHeaders option can set response headers by file extension, so an app can give HTML and versioned assets different policies.

A 304 response contains no replacement body, so the browser reuses its validated copy. In the Network panel, check the status of a conditional request to see whether the browser received new file contents.

Common Errors with Express Static Files

A static-file failure usually comes from a mismatch between the mounted URL, the filesystem root, and the middleware that handles the request. Check the response and the exact file path before changing the server configuration.

CaseSymptomCheck or fix
Relative root resolves elsewhereEvery asset is missing after a service or process launch changeUse an absolute root based on the module directory
Prefix repeated in the URLRequest includes the physical folder name as well as the mount prefixMap the request relative to the mounted root
Fallback route masks a missing assetA CSS URL returns HTML instead of a stylesheetCheck status and Content-Type, then review middleware order and fallback behavior
Unexpected duplicate fileA request resolves to an older copyCheck all mounted roots in declaration order

Check the root and request path separately

The first argument to express.static is a directory on the server. The browser uses a prefix only when you mount the middleware beneath it.

When public is the configured static root, Express maps public/images/logo.svg to /images/logo.svg, with no public segment in the URL. If you mount that root at /static, the URL becomes /static/images/logo.svg.

When every request fails, confirm the running app can see the directory and that the configured absolute path points to it. The filesystem and URL are separate namespaces, so changing the URL will not repair a root that points at the wrong place.

Understand middleware order and fallthrough

A missing file can continue to later middleware, so a route or single-page-app fallback may answer instead. That is why a miss can appear as an HTML response rather than a plain static-file 404.

Place static middleware before a broad fallback when the fallback should handle only requests that are not assets. If an API route and an asset share a URL, choose a clear prefix such as /static and keep the route paths distinct. This makes the request destination visible during debugging.

The serve-static fallthrough option changes how a missing-file request is handled. With fallthrough disabled, client errors are passed to Express error handling instead of continuing as an ordinary miss.

Read the response before changing cache settings

For a stylesheet, inspect both the status and the Content-Type response header. A successful status with text/html usually means a fallback returned a page, not that the browser received CSS. A correct text/css response narrows the problem to the browser URL, cached copy, or stylesheet contents.

Static middleware can set cache headers, and its maxAge option is expressed in milliseconds. Inspect the response header in the browser Network panel rather than assuming a cached asset changed.

A longer freshness period can let browsers reuse assets until their URLs change. Avoid applying that lifetime to stable URLs whose contents you expect to replace in place.

Keep the public root limited to public assets

Anything under a static root is available through a request URL without an application-specific authorization check. Keep credentials, private exports, backups, and user-specific documents outside that root. A hard-to-guess filename is not an access-control rule.

When a file must be delivered only to an authorized user, handle that request through an application route that checks access before sending the file. The related Express res.sendFile() guide covers that route-specific task.

Dotfiles are ignored by default by the static middleware. Do not enable dotfile serving just to make a missing file appear, because hidden configuration files can contain information that should never be web-accessible. Put intentionally public files in a clearly named directory and request them by their mapped URL.

Conclusion: Keep the URL and Folder Separate

For broader app setup, see the Express tutorials. For route organization, see the Express Router guide. For route-specific file responses, see Express res.sendFile().

FAQ

These answers cover the URL mapping and folder setup used above.

How do I serve static files in Express?

Mount express.static with app.use(), passing the directory that contains files intended for browser access. Use an absolute path such as path.join(__dirname, ‘public’) for a stable root.

Does the public folder name appear in the file URL?

No. When public is the static root, a file at public/images/logo.png is requested as /images/logo.png. A mount prefix such as /static adds that prefix to the URL.

Can Express serve files from more than one directory?

Yes. Mount express.static for each directory. Express searches them in declaration order, so place the preferred directory first.

Why does an Express static file return 404?

Check that the requested URL matches the mount prefix and the file’s path under the configured root. Confirm the file exists and compare capitalization on case-sensitive systems.

How do I use express.static in an ES module?

Derive the module directory from import.meta.url with fileURLToPath and path.dirname, then pass that absolute path to express.static. __dirname is not defined by default in ES modules.

Does Express list files in a static directory?

No. A directory request can return its index file, but express.static does not create a browsable directory listing.

Can express.static protect private downloads?

No. Static middleware serves files without an authorization check. Put user-specific downloads behind a route that verifies access before sending a file.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337