NextRush

@nextrush/template

Template engine plugin with support for EJS, Handlebars, Nunjucks, Pug, Eta, and a built-in zero-dependency Mustache-like engine.

Problem

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)

Standalone Rendering

Render templates without middleware:

import { render, renderAsync } from '@nextrush/template';

// Sync rendering
const html = render('Hello {{name}}!', { name: 'World' });

// Async rendering
const html = await renderAsync('Hello {{name}}!', { name: 'World' });

Template Plugin

Use as a plugin:

import { templatePlugin } from '@nextrush/template';

app.plugin(
  templatePlugin('ejs', {
    root: './views',
  })
);

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.


On this page