Express contains no template language. What it contains is a resolution pipeline: map a view name to a file, map the file's extension to an engine function, call the engine, send the result. The pipeline spans three files -- res.render (lib/response.js) merges locals and defaults the callback, app.render (lib/application.js) owns caching and View construction, and lib/view.js does path lookup and engine loading.
res.render: locals in, response out
res.render attaches the response-scoped locals and, when no callback is given, installs the default one -- errors go to next, output goes to res.send:
// merge res.locals
opts._locals = self.locals;
// default callback to respond
done = done || function (err, str) {
if (err) return req.next(err);
self.send(str);
};
// render
app.render(view, opts, done);
So a render failure is an ordinary routed error: it flows to your arity-4 error handler, not to an exception. Passing an explicit callback opts out of responding -- app.render alone is usable for non-HTTP output such as email bodies (the doc comment in lib/application.js uses exactly that example).
app.render: merge order and the cache
Locals merge with a spread, which fixes precedence -- app locals, then res.locals, then per-call options, later wins:
// merge options
var renderOptions = { ...this.locals, ...opts._locals, ...opts };
// set .cache unless explicitly provided
if (renderOptions.cache == null) {
renderOptions.cache = this.enabled('view cache');
}
The cache stores resolved View instances (not rendered output) keyed by name. view cache is enabled automatically when NODE_ENV=production (defaultConfiguration in lib/application.js); in development every render re-stats the filesystem so template edits show up without a restart. A lookup miss produces an error that names every directory searched -- "Failed to lookup view ... in views directory ..." -- one of the more recognizable Express error messages.
View: extension chooses the engine
The View constructor resolves the engine from the file extension, falling back to the view engine setting when the name has none. Unregistered extensions are require()d by convention -- module name equals extension, export named __express:
if (!opts.engines[this.ext]) {
// load engine
var mod = this.ext.slice(1)
debug('require "%s"', mod)
// default engine export
var fn = require(mod).__express
if (typeof fn !== 'function') {
throw new Error('Module "' + mod + '" does not provide a view engine.')
}
opts.engines[this.ext] = fn
}
That convention is why app.set('view engine', 'ejs') works with zero registration: rendering index requires ejs and uses ejs.__express. app.engine(ext, fn) exists for engines that do not follow the convention or to remap extensions -- app.engine('html', require('ejs').renderFile) is the example given in lib/application.js.
File lookup tries two shapes per root, in order: <name>.<ext>, then <name>/index.<ext> (View.prototype.resolve). The views setting may be an array of roots, searched in order.
One subtlety in View.prototype.render: engines that call their callback synchronously are normalized with process.nextTick, so render callbacks are always asynchronous regardless of engine -- release-Zalgo protection for user code.
Wiring an engine, end to end
examples/route-separation/index.js shows the standard two settings:
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
examples/markdown demonstrates the other direction -- registering a custom engine function for .md files via app.engine. The engine contract is a single signature: (path, options, callback).
Related settings (view, views, view engine, view cache) are listed with defaults in the app settings reference.