Serving Static Files and Template Engines

While the MEAN Stack uses Angular as its frontend framework, there are important scenarios where an Express server needs to serve static files directly โ€” serving the built Angular application, providing downloadable assets, serving uploaded images, or rendering server-side HTML for email templates and admin dashboards. Express’s built-in express.static() middleware handles static file serving efficiently, and template engines like EJS or Handlebars let you generate dynamic HTML on the server when needed. This lesson covers both, with practical examples for each use case you will encounter in a MEAN Stack project.

express.static() Options

Option Type Default Purpose
maxAge string / number 0 Cache-Control max-age in milliseconds or string ('1d')
etag boolean true Generate ETag headers for conditional GET
index string / false 'index.html' Default file for directory requests
dotfiles string 'ignore' 'allow', 'deny', or 'ignore' dotfiles (.env, .git)
redirect boolean true Redirect to trailing slash for directories
extensions string[] false Try these extensions if exact file not found
fallthrough boolean true Call next() if file not found (vs 404)
Engine npm Package File Extension Syntax Style
EJS ejs .ejs <%= variable %> โ€” familiar HTML with JS inserts
Handlebars express-handlebars .hbs {{ variable }} โ€” logic-less templates
Pug pug .pug Indentation-based โ€” no closing tags
Nunjucks nunjucks .njk Jinja2-inspired โ€” powerful macros and filters
Note: In a MEAN Stack application, Angular handles all frontend HTML rendering. You will use Express template engines primarily for server-generated content that is not part of the Angular SPA โ€” HTML email templates (registration confirmation, password reset), admin dashboards accessible without loading the Angular bundle, PDF generation, and server-rendered error pages. These are niche but important use cases.
Tip: When serving the built Angular application from Express, configure express.static() to serve the dist/ folder, then add a catch-all route that sends index.html for any path not matched by the API. This enables Angular’s client-side router to handle navigation โ€” without the catch-all, refreshing the page on an Angular route like /tasks/42 returns 404 because Express cannot find a file called tasks/42.
Warning: Never serve your entire project root as static files โ€” only a specific public directory. app.use(express.static(__dirname)) would serve your .env file, node_modules, and source code to anyone who requests them. Always serve only a dedicated public or dist directory: app.use(express.static(path.join(__dirname, 'public'))).

Basic Example

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

// โ”€โ”€ Serving static files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// Serve files from /public directory
// GET /logo.png       โ†’ serves /public/logo.png
// GET /css/style.css  โ†’ serves /public/css/style.css
app.use(express.static(path.join(__dirname, 'public')));

// Serve from a virtual path prefix
// GET /assets/logo.png โ†’ serves /public/logo.png
app.use('/assets', express.static(path.join(__dirname, 'public'), {
    maxAge:    '1d',        // cache for 1 day
    etag:      true,        // conditional GET support
    dotfiles:  'ignore',    // do not serve .env, .htaccess etc
}));

// Multiple static directories โ€” searched in order
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'uploads')));

// โ”€โ”€ Serving uploaded user files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const uploadsDir = path.join(__dirname, '..', 'uploads');
app.use('/uploads', express.static(uploadsDir, {
    dotfiles: 'deny',                   // never serve dotfiles
    maxAge: 0,                          // no caching for user content
    index: false,                       // disable directory listing
    fallthrough: false,                 // return 404 for missing files (not next())
}));

// โ”€โ”€ Serving the built Angular application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const angularDist = path.join(__dirname, '..', 'frontend', 'dist', 'frontend', 'browser');

// Serve Angular static files
app.use(express.static(angularDist, {
    maxAge: '1y',      // JS/CSS are content-hashed โ€” safe to cache aggressively
    etag: true,
}));

// API routes must be registered BEFORE the Angular catch-all
app.use('/api', require('./routes'));

// Angular catch-all โ€” send index.html for any non-API route
// This lets Angular's router handle navigation on the client
app.get('*', (req, res) => {
    res.sendFile(path.join(angularDist, 'index.html'));
});

Template Engines โ€” EJS for Server-Generated HTML

npm install ejs
// app.js โ€” configure EJS
const express = require('express');
const path    = require('path');
const app     = express();

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));  // directory for template files

// Render a template
app.get('/admin/users', async (req, res) => {
    const users = await User.find().lean();
    // Renders views/admin/users.ejs with { users, title } as local variables
    res.render('admin/users', {
        title: 'User Management',
        users,
        currentUser: req.user,
    });
});
<!-- views/admin/users.ejs -->
<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p>Logged in as: <%= currentUser.email %></p>

    <table>
        <thead>
            <tr><th>Name</th><th>Email</th><th>Role</th></tr>
        </thead>
        <tbody>
            <% users.forEach(user => { %>
            <tr>
                <td><%= user.name %></td>
                <td><%= user.email %></td>
                <td><%= user.role %></td>
            </tr>
            <% }) %>
        </tbody>
    </table>
</body>
</html>

Real-World Example: Email Template Renderer

// services/email.service.js
// Uses EJS to render HTML emails, then sends via nodemailer

const ejs        = require('ejs');
const path       = require('path');
const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
    host:   process.env.SMTP_HOST,
    port:   Number(process.env.SMTP_PORT),
    auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASS,
    },
});

const TEMPLATES_DIR = path.join(__dirname, '..', 'views', 'emails');

async function renderTemplate(name, data) {
    const file = path.join(TEMPLATES_DIR, `${name}.ejs`);
    return ejs.renderFile(file, data);
}

async function sendWelcomeEmail(user) {
    const html = await renderTemplate('welcome', {
        name:          user.name,
        loginUrl:      `${process.env.FRONTEND_URL}/auth/login`,
        supportEmail:  'support@taskmanager.io',
    });

    await transporter.sendMail({
        from:    '"Task Manager" <noreply@taskmanager.io>',
        to:      user.email,
        subject: 'Welcome to Task Manager!',
        html,
    });
}

async function sendPasswordResetEmail(user, resetToken) {
    const resetUrl = `${process.env.FRONTEND_URL}/auth/reset-password?token=${resetToken}`;
    const html     = await renderTemplate('password-reset', { name: user.name, resetUrl });

    await transporter.sendMail({
        from:    '"Task Manager" <noreply@taskmanager.io>',
        to:      user.email,
        subject: 'Password Reset Request',
        html,
    });
}

module.exports = { sendWelcomeEmail, sendPasswordResetEmail };
<!-- views/emails/welcome.ejs -->
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
    <h1 style="color: #2563eb;">Welcome, <%= name %>!</h1>
    <p>Your Task Manager account is ready. Start organising your work today.</p>
    <a href="<%= loginUrl %>"
       style="background:#2563eb;color:#fff;padding:12px 24px;
              border-radius:6px;text-decoration:none;display:inline-block;">
        Log In Now
    </a>
    <p style="color:#6b7280;font-size:12px;margin-top:32px;">
        Need help? Email <%= supportEmail %>
    </p>
</div>

How It Works

Step 1 โ€” express.static() Reads Files from the Filesystem

When a request arrives for a static path, Express checks if a matching file exists in the configured directory. If it does, Express reads the file, sets the appropriate Content-Type header based on the file extension, adds cache headers, and streams the file to the client. If the file does not exist and fallthrough is true (the default), Express calls next() to pass the request to the next middleware.

Step 2 โ€” ETags Enable Efficient Caching

Express generates an ETag (a hash of the file’s content) and includes it in the response as an ETag header. On subsequent requests, the browser sends the ETag in an If-None-Match header. Express compares the ETags โ€” if they match, it returns 304 Not Modified with no body. The browser uses its cached copy. This is dramatically more efficient than re-sending unchanged files.

Step 3 โ€” The Angular Catch-All Route Enables SPA Navigation

Angular is a Single Page Application โ€” all navigation happens in the browser using the Angular Router. The server only serves index.html once. But if a user refreshes the page at /tasks/42, the browser sends a GET request to the server for that path. Without a catch-all route, Express returns 404 because there is no file called tasks/42. The catch-all returns index.html, Angular loads, reads the URL, and navigates to the correct route.

Step 4 โ€” Template Engines Compile Templates to HTML Strings

res.render('template', locals) looks up the template file in the configured views directory, passes the local variables to the engine, and the engine returns an HTML string. Express sends this as the response with Content-Type: text/html. EJS templates are compiled and cached in production for performance โ€” each template is only parsed once.

Step 5 โ€” Static Files and API Routes Coexist Through Order

Express processes middleware in registration order. By registering API routes before the catch-all static handler, API calls are handled first. Requests to /api/* are handled by the router. Everything else falls through to express.static(), and if no file matches, to the Angular catch-all. This ordering is essential โ€” reversing it would cause the catch-all to intercept API requests.

Common Mistakes

Mistake 1 โ€” Serving the project root as static files

โŒ Wrong โ€” exposes .env, source code, and node_modules:

app.use(express.static(__dirname));      // serves EVERYTHING including .env!
app.use(express.static('.'));            // same problem โ€” relative to cwd

✅ Correct โ€” serve only the public directory:

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

Mistake 2 โ€” Registering the Angular catch-all before API routes

โŒ Wrong โ€” catch-all intercepts API requests, returns index.html for /api/tasks:

app.get('*', (req, res) => res.sendFile(join(dist, 'index.html')));
app.use('/api', apiRoutes);   // NEVER REACHED โ€” * matches everything first

✅ Correct โ€” API routes before the catch-all:

app.use('/api', apiRoutes);   // matches /api/* first
app.get('*',   (req, res) => res.sendFile(join(dist, 'index.html')));

Mistake 3 โ€” Using res.render() without configuring the view engine

โŒ Wrong โ€” Error: No default engine was specified:

app.get('/page', (req, res) => res.render('index', { title: 'Home' }));
// Error: No default engine was specified and no extension was provided

✅ Correct โ€” configure view engine and views directory first:

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.get('/page', (req, res) => res.render('index', { title: 'Home' }));

Quick Reference

Task Code
Serve static files app.use(express.static(path.join(__dirname, 'public')))
With virtual prefix app.use('/assets', express.static('public'))
With caching express.static('public', { maxAge: '1d' })
Angular catch-all app.get('*', (req, res) => res.sendFile(join(dist, 'index.html')))
Configure EJS app.set('view engine', 'ejs'); app.set('views', 'views')
Render template res.render('template-name', { key: value })
Render to string await ejs.renderFile(filePath, data)
Send file download res.download(filePath, 'filename.pdf')

🧠 Test Yourself

A user navigates to /tasks/42 in an Angular SPA served by Express. They refresh the page and get a 404. What is the fix?