ReferenceResponses & Views
@nextrush/template

Templates

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          # Eta

Minimal 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

EngineNameDefault 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

PropertyTypeDescription
rootstring= "./views"Root directory for template files
extstring= ".html"Default file extension (varies by engine)
cacheboolean= true in productionCache compiled templates
layout?stringDefault layout template name
helpers?Record<string, Function>Custom helper functions
enableContextRenderboolean= trueAttach 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

SyntaxDescription
{{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 the safe helper 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 configured root.

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

PropertyTypeDescription
rootstring= process.cwd()Root directory; template and partials-directory lookups reject a resolved path outside it.
extstring= '.html'Default extension for template files without one.
cacheboolean= process.env.NODE_ENV === "production"Enables the compiled-template cache.
layout?string | null= nullDefault layout wrapping every render() call.
partialsDir?string | null= nullDirectory 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

RuntimeSupportedNotes
Node.js >=22YesESM-only; the built-in engine's file loader and TemplateEngine use node:fs/promises/node:path directly
Bun / Deno / EdgeNot claimedNo 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.


Was this helpful?

On this page