@nextrush/templateTemplates
Template engine plugin with support for EJS, Handlebars, Nunjucks, Pug, Eta, and a built-in zero-dependency Mustache-like engine.
Problem
Source & internals
Server-rendered HTML requires a template engine, but each engine has a different API, file loading strategy, and caching mechanism. Switching engines means rewriting route handlers and learning a new integration pattern.
The template plugin provides a unified interface across six engines. You swap engines by changing a single string — route handlers stay the same.
Default Behavior
With no configuration, the plugin uses the built-in Mustache-like engine, reads templates from ./views, uses .html as the file extension, and enables caching when NODE_ENV=production. All output is HTML-escaped by default to prevent XSS.
Installation
$ pnpm add @nextrush/template
Install an optional engine if you prefer one over the built-in:
pnpm add ejs # EJS
pnpm add handlebars # Handlebars
pnpm add nunjucks # Nunjucks
pnpm add pug # Pug
pnpm add eta # EtaMinimal Usage
import { createApp } from '@nextrush/core';
import { template } from '@nextrush/template';
const app = createApp();
// Use built-in engine (no extra dependencies)
app.use(template({ root: './views' }));
app.get('/', async (ctx) => {
await ctx.render('home', { title: 'Hello World' });
});ctx.render() loads ./views/home.html, merges ctx.state with the data you pass, renders the template, and sends the result as an HTML response.
Supported Engines
| Engine | Name | Default Extension |
|---|---|---|
| Built-in | 'builtin' | .html |
| EJS | 'ejs' | .ejs |
| Handlebars | 'handlebars' | .hbs |
| Nunjucks | 'nunjucks' | .njk |
| Pug | 'pug' | .pug |
| Eta | 'eta' | .eta |
Pass the engine name as the first argument:
app.use(template('ejs', { root: './views' }));
app.use(template('handlebars', { root: './views', ext: '.hbs' }));Configuration Options
Options
| Property | Type | Description |
|---|---|---|
root | string= "./views" | Root directory for template files |
ext | string= ".html" | Default file extension (varies by engine) |
cache | boolean= true in production | Cache compiled templates |
layout? | string | Default layout template name |
helpers? | Record<string, Function> | Custom helper functions |
enableContextRender | boolean= true | Attach ctx.render() method to context |
Rendering Templates
app.get('/', async (ctx) => {
// Render with data
await ctx.render('home', {
title: 'Welcome',
users: [{ name: 'Alice' }, { name: 'Bob' }],
});
});The ctx.state is automatically merged with render data:
app.use(async (ctx) => {
ctx.state.currentYear = new Date().getFullYear();
await ctx.next();
});
app.get('/', async (ctx) => {
// currentYear is available in template
await ctx.render('home', { title: 'Hello' });
});Layouts
Define a base layout:
app.use(
template('handlebars', {
root: './views',
layout: 'layouts/main',
})
);<!-- views/layouts/main.hbs -->
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
</head>
<body>
{{{body}}}
</body>
</html><!-- views/home.hbs -->
<h1>{{title}}</h1>
<p>Content goes here</p>Helpers
Add custom helpers:
app.use(
template('handlebars', {
root: './views',
helpers: {
uppercase: (str) => str.toUpperCase(),
formatDate: (date) => new Date(date).toLocaleDateString(),
json: (obj) => JSON.stringify(obj, null, 2),
},
})
);Use in templates:
<h1>{{uppercase title}}</h1>
<p>Published: {{formatDate createdAt}}</p>Built-in Engine
The default engine uses Mustache-like syntax:
app.use(template({ root: './views' }));<!-- views/home.html -->
<h1>{{title}}</h1>
{{#if user}}
<p>Welcome, {{user.name}}!</p>
{{/if}} {{#each items}}
<li>{{name}} - {{price}}</li>
{{/each}} {{#unless loggedIn}}
<a href="/login">Login</a>
{{/unless}}XSS Risk with Raw Output
Triple-mustache {{{var}}} disables HTML escaping. Never use it with user-supplied data. All user input must go through double-mustache {{var}} for safe escaped output.
Built-in Syntax
| Syntax | Description |
|---|---|
{{var}} | Output escaped value |
{{{var}}} | Output raw HTML |
{{#if}}...{{/if}} | Conditional |
{{#unless}}...{{/unless}} | Negative conditional |
{{#each}}...{{/each}} | Iteration |
{{! comment }} | Comment (not rendered) |
{{> partialName}} | Partial inclusion |
{{value | helper}} | Pipe a value through a registered helper |
50+ built-in helpers are available across string (upper, lower, capitalize, truncate,
stripHtml, ...), number (formatNumber, currency, percent, round, ...), date
(formatDate, timeAgo, day, month, year, now), array/object (first, last, sort,
unique, keys, values, get, ...), and comparison (eq, ne, gt, lt, and, or,
not) categories, plus json/safe for output control.
Security
Built-in guards, not opt-in configuration
These protections apply to the built-in engine and the TemplateEngine/createEngine file-
loading paths. The EJS/Handlebars/Nunjucks/Pug/Eta adapters delegate escaping and recursion
behavior to that engine's own library instead.
- HTML-escaping is on by default.
{{variable}}interpolation escapes its output (compile.escape: true); only{{{variable}}},{{& variable}}, or thesafehelper bypass it, and only for content you control. - Prototype-pollution protection on property access. A dotted path like
{{user.name}}blocks__proto__,constructor,prototype, and getter/setter dunder properties — a template cannot read or trigger a prototype-pollution-style property chain. - Recursion is depth-guarded. The compiler caps nested render calls at 100
(
MAX_RECURSION_DEPTH); the built-in file-based adapter additionally caps layout nesting at 10 (MAX_LAYOUT_DEPTH). Exceeding either throws rather than looping unbounded. - Template and partials paths are traversal-checked.
TemplateEngine's file loader rejects a resolved template or partials-directory path that would escape its configuredroot.
Standalone Rendering
Render template strings directly, without middleware or a Context:
import { render, renderAsync } from '@nextrush/template';
// Sync rendering
const html = render('Hello {{name}}!', { name: 'World' });
// Async rendering (supports async helpers)
const htmlAsync = await renderAsync('{{#each items}}{{this}} {{/each}}', {
items: ['a', 'b', 'c'],
});TemplateEngine — file-based rendering outside middleware
createEngine() gives you the same file loading, caching, and layout support template() uses
internally, without attaching ctx.render() to any request:
import { createEngine } from '@nextrush/template';
const engine = createEngine({ root: './views', cache: true });
const html = await engine.render('home', { title: 'Hello' });EngineOptions
| Property | Type | Description |
|---|---|---|
root | string= process.cwd() | Root directory; template and partials-directory lookups reject a resolved path outside it. |
ext | string= '.html' | Default extension for template files without one. |
cache | boolean= process.env.NODE_ENV === "production" | Enables the compiled-template cache. |
layout? | string | null= null | Default layout wrapping every render() call. |
partialsDir? | string | null= null | Directory to auto-load partials from. |
helpers? | Record<string, HelperFn | ValueHelper> | Custom helpers merged with the built-ins. |
partials? | Record<string, string> | Inline partial sources registered at construction. |
createAdapter(engine?, config?), createViewEngine() (an Express-compatible app.engine(...)
function), and registerAdapter(name, factory) for a custom engine are also exported — see the
package's own README for the full adapter-registration API.
Compatibility
| Runtime | Supported | Notes |
|---|---|---|
| Node.js >=22 | Yes | ESM-only; the built-in engine's file loader and TemplateEngine use node:fs/promises/node:path directly |
| Bun / Deno / Edge | Not claimed | No adapter abstraction or conformance-suite coverage. The pure string-based render()/renderAsync()/compile() functions have no Node dependency, but file-based rendering (ctx.render(), TemplateEngine, any adapter's renderFile()) does |
Peer dependencies (all optional): @nextrush/core (for the Context/Middleware type
contracts), plus ejs@^3.0.0, eta@^3.0.0, handlebars@^4.0.0, nunjucks@^3.0.0, pug@^3.0.0
per chosen engine — none are installed automatically.
Common Mistakes
Forgetting to install the engine package. Passing 'ejs' without the ejs npm package installed causes an error at startup. Install the engine before using it.
Using {{{raw}}} for user input. Triple-mustache disables HTML escaping. Use it only for content you control. User-supplied data must go through {{escaped}}.
Expecting ctx.render() without the middleware. The render method is attached by the template middleware. If you skip app.use(template()), ctx.render is undefined.
Troubleshooting
"Template not found" error — Verify the root directory exists and the file name matches. The engine appends the configured ext automatically, so ctx.render('home') looks for ./views/home.html by default.
Stale templates in development — Caching activates automatically in production. In development, set cache: false if templates do not reflect changes.
Layout not applying — Confirm the layout file exists in the root directory and the file extension matches the engine default.
Related
- Reference — All packages
- @nextrush/static — Static file serving