SSR Templates

SpaceNode includes a built-in template engine for server-side rendering — zero external dependencies. Templates are AOT-compiled into JavaScript functions on first use, then cached via an LRU cache for near-instant re-renders.

No EJS, no Handlebars, no Pug. SpaceNode templates compile to native JavaScript with Proxy + with for safe scope resolution and automatic HTML escaping (XSS protection).

Overview

Syntax[= expr], [# if/each/block], [> include]
CompilationAOT — template → tokens → AST → JavaScript function
CachingLRU cache (default 500 entries)
EscapingAuto HTML-escaping (& < > " '), opt-out via raw()
LayoutsSingle-level layout with body variable + named blocks
IncludesPartials with optional scope isolation
Pipe Filtersvalue | filter:arg — chainable transformations
Helpers10 built-in + custom via addHelper()

Quick Start

1. Enable Views

Pass views in createApp() to activate the template engine:

import { createApp } from 'spacenode'

const app = await createApp({
  views: './views',   // path to templates directory
})
app.listen(3000)

2. Create a Template

Create views/home.html:

<h1>Hello, [= name]!</h1>
<p>Welcome to [= siteName].</p>

3. Render from Controller

export default {
  routes: {
    'GET /': 'home',
  },

  async home(req) {
    return req.render('home', { name: 'World' })
  },
}

4. settings.js — Global Configuration

Create views/settings.js for automatic layout and global variables. The engine auto-discovers this file.

KeyTypeDescription
layoutstringDefault layout template name (e.g. 'layout'views/layout.html)
globalsobjectVariables available in every template without passing them via render()
helpersobjectCustom helper functions — alternative to app.addHelper()
export default {
  layout: 'layout',
  globals: {
    siteName: 'My Site',
    year: new Date().getFullYear(),
  },
  helpers: {
    slugify: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
    initials: (v) => String(v).split(' ').map(w => w[0]).join(''),
  },
}

All globals are injected into every template automatically. helpers become available as pipe filters: [= title | slugify].


Template Syntax

Quick Reference

SyntaxPurposeExample
[= expr]Output expression (auto-escaped)[= user.name]
[= raw(expr)]Output without escaping[= raw(body)]
[= expr | filter]Pipe filter[= name | upper]
[= expr | f:arg]Pipe with arguments[= text | truncate:50]
[# if cond]...[/if]Conditional block[# if user]...[/if]
[# if]...[# else]...[/if]If / else[# if user]...[# else]...[/if]
[# each arr as x]...[/each]Loop over array[# each users as u]...[/each]
[# each arr as x, i]...[/each]Loop with index[# each items as item, i]
[> file]Include partial[> nav]
[> file { data }]Include with isolated scope[> card { title: 'Hi' }]
[# block name]...[/block]Named block for layout[# block styles]...[/block]

Expressions — [= expr]

Output a JavaScript expression. Automatically HTML-escaped for XSS protection:

<p>[= user.name]</p>
<p>[= items.length]</p>
<p>[= price * 1.2]</p>
<p>[= user.isAdmin ? 'Admin' : 'User']</p>

To output raw (unescaped) HTML, use the raw() wrapper:

[= raw(htmlContent)]   <!-- no escaping -->

Conditionals — [# if]

[# if user]
  <p>Hello, [= user.name]!</p>
[# else]
  <p>Please log in.</p>
[/if]

Supports any JavaScript expression as the condition:

[# if user.role === 'admin']
  <a href="/admin">Admin Panel</a>
[/if]

[# if items.length > 0]
  <p>[= items.length] items in cart</p>
[/if]

Loops — [# each]

<ul>
[# each users as user]
  <li>[= user.name][= user.email]</li>
[/each]
</ul>

Optional index variable:

[# each items as item, i]
  <div>#[= i + 1]: [= item.title]</div>
[/each]

Includes — [> partial]

Include another template file (resolved relative to views directory):

[> nav]           <!-- includes views/nav.html -->
[> footer]        <!-- includes views/footer.html -->

Pass data to an include for scope isolation:

[> card { title: product.name, price: product.price }]

When data is passed, the included template only sees those variables (plus globals and helpers). Without data, the include inherits the parent scope.

Blocks — [# block]

Define named content blocks that are injected into the layout:

Page template (home.html):

[# block styles]
  <link rel="stylesheet" href="/css/home.css">
[/block]

[# block scripts]
  <script src="/js/home.js"></script>
[/block]

<h1>Home Page</h1>

Layout template (layout.html):

<!DOCTYPE html>
<html>
<head>
  <title>[= siteName]</title>
  [= raw(styles)]    <!-- injected from block -->
</head>
<body>
  [> nav]
  [= raw(body)]      <!-- page content -->
  [> footer]
  [= raw(scripts)]   <!-- injected from block -->
</body>
</html>

Blocks are captured during page rendering and passed to the layout as regular variables. Use raw() to output them unescaped.


Pipe Filters

Transform values inline using the pipe syntax value | filter or value | filter:arg1:arg2. Pipes are chainable:

[= user.name | upper]
[= description | truncate:50]
[= user.name | lower | capitalize]
[= price | currency:'EUR']
[= count | plural:'item':'items']
Pipe separator: Uses | (space-pipe-space) to avoid conflict with the JavaScript || logical OR operator. The engine correctly handles pipes inside string literals.

Built-in Helpers

HelperSyntaxDescription
raw[= raw(expr)]Skip HTML escaping — use as wrapper only, not as pipe
uppervalue | upperConvert to UPPERCASE
lowervalue | lowerConvert to lowercase
capitalizevalue | capitalizeCapitalize first letter
truncatevalue | truncate:100:'...'Truncate to length, add suffix
datevalue | date:'YYYY-MM-DD'Format date (YYYY, MM, DD, HH, mm, ss)
jsonvalue | jsonJSON.stringify
padvalue | pad:2:'0'Pad string to length with character
pluralcount | plural:'item':'items'Pluralize: "1 item" vs "3 items"
currencyprice | currency:'USD'Format as currency (locale-aware)

Custom Helpers

Register custom helpers via the app API:

app.addHelper('slugify', (v) => {
  return String(v)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '')
})

// In template:
// [= title | slugify]  →  "hello-world"

Layout System

Layouts are 100% user-defined. The engine does not include any built-in layout. It only provides the mechanism — you create the layout file yourself with whatever structure your project needs.

What is a Layout?

A layout is a regular .html template that serves as a shared shell for all pages — the common DOCTYPE, <head>, navigation, footer, and scripts. Individual pages only contain their unique content; the layout wraps it automatically.

Without a layout, every page template would need to duplicate the full HTML structure. With a layout, you write it once:

<!-- views/layout.html — YOU create this file -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>[= title || siteName]</title>
  [= raw(styles)]        <!-- per-page CSS from [# block styles] -->
</head>
<body>
  [> nav]               <!-- shared navigation partial -->
  <main>
    [= raw(body)]        <!-- ← page content injected HERE -->
  </main>
  [> footer]            <!-- shared footer partial -->
  [= raw(scripts)]      <!-- per-page JS from [# block scripts] -->
</body>
</html>

How It Works — Step by Step

When you call req.render('home', { title: 'Home' }), the engine performs two render passes:

Pass 1 — Render the page: Compile and execute home.html. Output: HTML string + named blocks (styles, scripts, etc.)

Pass 2 — Render the layout: Compile and execute layout.html with these variables:
  • body — the HTML output from Pass 1
  • styles, scripts, etc. — all named blocks from Pass 1
  • title, siteName, etc. — original data + globals

Visual flow:

req.render('home', { title: 'Home' })

   Pass 1: home.html
   ┌─────────────────────────────┐
   │ [# block styles]            │ → captured as `styles` variable
   │   <link href="home.css">    │
   │ [/block]                    │
   │                             │
   │ <h1>Welcome!</h1>          │ → captured as `body` variable
   │ <p>Hello, [= name]</p>    │
   └─────────────────────────────┘
              ↓
   Pass 2: layout.html
   ┌─────────────────────────────┐
   │ <!DOCTYPE html>             │
   │ <head>                      │
   │   [= raw(styles)] ←──────────── <link href="home.css">
   │ </head>                     │
   │ <body>                      │
   │   [> nav]                   │
   │   [= raw(body)] ←────────────── <h1>Welcome!</h1>...
   │   [> footer]                │
   │   [= raw(scripts)]          │
   │ </body>                     │
   └─────────────────────────────┘
              ↓
   Final HTML sent to browser

Configure Layout

Set a default layout for all pages via views/settings.js:

export default {
  layout: 'layout',    // → uses views/layout.html for every render
  globals: {
    siteName: 'My Site',
    year: new Date().getFullYear(),
  },
}

Every req.render() call will now wrap pages in layout.html automatically. You don't need to specify it each time.

Override or Disable

You can override the layout per-render, or disable it entirely:

// Use a different layout for admin pages
req.render('admin/dashboard', data, { layout: 'admin-layout' })

// Disable layout — returns just the page HTML fragment
req.render('fragment', data, { layout: false })

What Happens Without a Layout?

If no layout is configured (no layout in settings.js, no layout option in render()), the engine skips Pass 2 entirely. It returns only the page template output — no DOCTYPE, no <head>, no navigation.

// settings.js — no layout defined
export default {
  globals: { siteName: 'My App' },
}

// home.html
// <h1>Hello, [= name]!</h1>

// Result: just "<h1>Hello, World!</h1>" — no wrapper

This is useful for:

Multiple Layouts

You can have as many layout files as needed. Common pattern — separate layouts for public site and admin panel:

views/
├── layout.html          // public site layout (nav + footer)
├── admin-layout.html    // admin layout (sidebar + toolbar)
├── email-layout.html    // email wrapper (inline styles)
├── home.html
├── admin/
│   └── dashboard.html
└── email/
    └── welcome.html
// Public pages — use default layout from settings.js
req.render('home', data)

// Admin pages — override with admin layout
req.render('admin/dashboard', data, { layout: 'admin-layout' })

// Emails — render outside controller via ViewEngine
const html = await app._viewEngine.render('email/welcome', data, { layout: 'email-layout' })

The body Variable

The engine reserves one special variable name: body. After rendering the page template, its HTML output is assigned to body and passed to the layout. In the layout, use [= raw(body)] to inject the page content.

Always use raw(body) in the layout, not [= body]. Without raw(), the HTML will be escaped and displayed as text instead of rendered.

Blocks in Layouts

Blocks allow pages to inject content into specific places in the layout (like per-page CSS or JS). Each [# block name]...[/block] captures its content into a variable that the layout receives:

<!-- home.html -->
[# block styles]
  <link rel="stylesheet" href="/css/home.css">
[/block]

[# block scripts]
  <script src="/js/charts.js"></script>
[/block]

<h1>Dashboard</h1>
<canvas id="chart"></canvas>
<!-- layout.html -->
<head>
  [= raw(styles)]      <!-- ← home.css injected here -->
</head>
<body>
  [= raw(body)]       <!-- ← h1 + canvas injected here -->
  [= raw(scripts)]    <!-- ← charts.js injected here -->
</body>

If a page doesn't define a block, the variable is simply undefined and nothing is output in the layout (no errors).


Rendering API

req.render(name, data, options)

Available inside any controller handler via the request context:

async home(req) {
  return req.render('home', {
    title: 'Home Page',
    user: req.user,
    flash: req.flash,
  })
}
ParameterTypeDescription
namestringTemplate name without .html (relative to views dir)
dataobjectVariables available in the template
options.layoutstring | falseOverride layout or disable with false

Auto-injected Variables

Every req.render() call automatically injects these variables into the template data (you don't need to pass them manually):

VariableTypeDescriptionUsage in template
flashesobjectFlash messages by category (success, error, etc.)[# if flashes.success]
csrfTokenstringCSRF token value (when csrf guard is enabled)[= csrfToken]
csrfFieldstringReady-to-use <input type="hidden"> with CSRF token[= raw(csrfField)]
Tip: Use [= raw(csrfField)] inside <form> tags to automatically include the CSRF hidden input. Use raw() because csrfField contains HTML.

app.render(method, path, template, ...)

Register a view route that renders a template when the route is matched. This is a shortcut for creating a route + controller that calls req.render():

// Static data
app.render('GET', '/about', 'about', { year: 2025 })

// Dynamic data via callback
app.render('GET', '/users', 'users', async (req, s) => ({
  users: await s.userService.all(),
}))

// With guards (pipes) + data + layout override
app.render('GET', '/dashboard', 'admin/dashboard', ['auth'], async (req) => ({
  user: req.user,
}), { layout: 'admin-layout' })

To render a template outside of a request (e.g., for emails), use the ViewEngine directly:

const html = await app._viewEngine.render('email/welcome', {
  username: 'John',
  activationLink: link,
})

Common Patterns

Practical recipes for common use cases in SSR templates.

Forms with CSRF Protection

<form method="POST" action="/login">
  [= raw(csrfField)]            <!-- auto-injected hidden input -->
  <input name="email" type="email">
  <input name="password" type="password">
  <button type="submit">Login</button>
</form>

Flash Messages

[# if flashes.success]
  [# each flashes.success as msg]
    <div class="alert alert-success">[= msg]</div>
  [/each]
[/if]

[# if flashes.error]
  [# each flashes.error as msg]
    <div class="alert alert-error">[= msg]</div>
  [/each]
[/if]

Conditional CSS Classes

<nav>
  <a href="/" class="[# if page === 'home']active[/if]">Home</a>
  <a href="/about" class="[# if page === 'about']active[/if]">About</a>
</nav>

Reusable Card Component

<!-- views/card.html -->
<div class="card">
  <h3>[= title]</h3>
  <p>[= description | truncate:100]</p>
  <span class="price">[= price | currency:'USD']</span>
</div>

<!-- Using in a page with scope isolation -->
[# each products as product]
  [> card { title: product.name, description: product.desc, price: product.price }]
[/each]

Numbered List with Index

<table>
  <tr><th>#</th><th>Name</th><th>Email</th></tr>
  [# each users as user, i]
    <tr>
      <td>[= i + 1]</td>
      <td>[= user.name | capitalize]</td>
      <td>[= user.email]</td>
    </tr>
  [/each]
</table>

Per-page Meta Tags via Blocks

<!-- views/layout.html -->
<head>
  <title>[= title || siteName]</title>
  [= raw(meta)]
  [= raw(styles)]
</head>

<!-- views/about.html -->
[# block meta]
  <meta name="description" content="About our company">
  <meta property="og:title" content="About Us">
[/block]

[# block styles]
  <link rel="stylesheet" href="/css/about.css">
[/block]

<h1>About Us</h1>

Rendering Without Layout (AJAX Fragment)

// Controller — return just the HTML fragment, no layout
async searchResults(req) {
  const results = await search(req.query.q)
  return req.render('partials/results', { results }, { layout: false })
}

<!-- views/partials/results.html -->
[# if results.length]
  [# each results as item]
    <div class="result">
      <a href="[= item.url]">[= item.title]</a>
      <p>[= item.snippet | truncate:120]</p>
    </div>
  [/each]
[# else]
  <p>No results found.</p>
[/if]

XSS Protection

All [= expr] expressions are auto-escaped by default. The engine replaces dangerous characters with HTML entities:

CharacterEntity
&&amp;
<&lt;
>&gt;
"&quot;
'&#39;

To output raw HTML (e.g., content you trust), use raw() as a wrapper function:

[= raw(trustedHtml)]     <!-- skip escaping -->
[= raw(body)]            <!-- layout body injection -->
raw only works as a wrapper function[= raw(expr)]. Using it as a pipe filter [= expr | raw] will NOT skip escaping, because the compiler only recognizes the raw(...) wrapper syntax.

LRU Cache

Compiled render functions are stored in an LRU (Least Recently Used) cache:

Clear the cache manually (e.g., in development):

app._viewEngine.clearCache()

ViewEngine API

The ViewEngine class is created internally by createApp() and stored as app._viewEngine. The most common operation — adding helpers — is available via the public app.addHelper(name, fn) method:

MethodDescription
render(name, data, options)Render a template with optional layout override
addHelper(name, fn)Register a custom helper function
clearCache()Clear the compiled template and source caches

Constructor Options

OptionTypeDefaultDescription
dirstringAbsolute path to views directory
layoutstringnullDefault layout template name
globalsobject{}Global variables for all templates
helpersobject{}Custom helper functions
cacheMaxnumber500Maximum LRU cache entries

Path Resolution & Security

Template files are resolved from the views directory:

Path traversal protection: The engine checks that the resolved file path starts with the views directory. Attempts like '../../etc/passwd' are blocked with an error.

Complete Example

Here is a minimal SSR app with layout, nav, homepage, and a dynamic page:

views/settings.js

export default {
  layout: 'layout',
  globals: {
    siteName: 'My App',
    year: new Date().getFullYear(),
  },
}

views/layout.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>[= title || siteName]</title>
  [= raw(styles)]
</head>
<body>
  [> nav]
  <main>[= raw(body)]</main>
  <footer>&copy; [= year] [= siteName]</footer>
  [= raw(scripts)]
</body>
</html>

views/nav.html

<nav>
  <a href="/">[= siteName]</a>
  [# if user]
    <a href="/profile">[= user.name]</a>
    <a href="/logout">Logout</a>
  [# else]
    <a href="/login">Login</a>
  [/if]
</nav>

views/home.html

[# block styles]
  <style>.hero { text-align: center; padding: 4rem; }</style>
[/block]

<div class="hero">
  <h1>Welcome to [= siteName]</h1>
  [# if user]
    <p>Hello, [= user.name | capitalize]!</p>
  [# else]
    <p><a href="/login">Login</a> to get started.</p>
  [/if]
</div>

modules/home/home.controller.js

export default {
  routes: {
    'GET /': 'home',
  },

  async home(req) {
    return req.render('home', {
      title: 'Home',
      user: req.user || null,
    })
  },
}

Summary

FeatureDetails
Syntax[= expr], [# if/each/block], [> include], [/close]
CompilationAOT: Source → Tokens → AST → JS Function (cached)
EscapingAuto HTML-escaping on all expressions, raw() to opt-out
LayoutsSingle-level with body + named blocks
IncludesPartials with optional scope isolation via [> file { data }]
Pipe FiltersChainable: value | upper | truncate:50
Built-in Helpersraw, upper, lower, capitalize, truncate, date, json, pad, plural, currency
Custom Helpersapp.addHelper(name, fn) or helpers in settings.js
CachingLRU with configurable max (default 500)
SecurityAuto-escaping + path traversal protection
DependenciesZero — built into SpaceNode core