SpaceNode
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.
Proxy + with for safe scope resolution and automatic HTML escaping (XSS protection).| Syntax | [= expr], [# if/each/block], [> include] |
| Compilation | AOT — template → tokens → AST → JavaScript function |
| Caching | LRU cache (default 500 entries) |
| Escaping | Auto HTML-escaping (& < > " '), opt-out via raw() |
| Layouts | Single-level layout with body variable + named blocks |
| Includes | Partials with optional scope isolation |
| Pipe Filters | value | filter:arg — chainable transformations |
| Helpers | 10 built-in + custom via addHelper() |
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)
Create views/home.html:
<h1>Hello, [= name]!</h1>
<p>Welcome to [= siteName].</p>
export default {
routes: {
'GET /': 'home',
},
async home(req) {
return req.render('home', { name: 'World' })
},
}
Create views/settings.js for automatic layout and global variables. The engine auto-discovers this file.
| Key | Type | Description |
|---|---|---|
layout | string | Default layout template name (e.g. 'layout' → views/layout.html) |
globals | object | Variables available in every template without passing them via render() |
helpers | object | Custom 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].
| Syntax | Purpose | Example |
|---|---|---|
[= 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] |
[= 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 -->
[# 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]
[# 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]
[> 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.
[# 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.
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']
| (space-pipe-space) to avoid conflict with the JavaScript || logical OR operator. The engine correctly handles pipes inside string literals.| Helper | Syntax | Description |
|---|---|---|
raw | [= raw(expr)] | Skip HTML escaping — use as wrapper only, not as pipe |
upper | value | upper | Convert to UPPERCASE |
lower | value | lower | Convert to lowercase |
capitalize | value | capitalize | Capitalize first letter |
truncate | value | truncate:100:'...' | Truncate to length, add suffix |
date | value | date:'YYYY-MM-DD' | Format date (YYYY, MM, DD, HH, mm, ss) |
json | value | json | JSON.stringify |
pad | value | pad:2:'0' | Pad string to length with character |
plural | count | plural:'item':'items' | Pluralize: "1 item" vs "3 items" |
currency | price | currency:'USD' | Format as currency (locale-aware) |
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"
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>
When you call req.render('home', { title: 'Home' }), the engine performs two render passes:
home.html. Output: HTML string + named blocks (styles, scripts, etc.)layout.html with these variables:
body — the HTML output from Pass 1styles, scripts, etc. — all named blocks from Pass 1title, siteName, etc. — original data + globalsVisual 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
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.
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 })
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:
app._viewEngine.render() outside of controllersYou 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' })
body VariableThe 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.
raw(body) in the layout, not [= body]. Without raw(), the HTML will be escaped and displayed as text instead of rendered.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).
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,
})
}
| Parameter | Type | Description |
|---|---|---|
name | string | Template name without .html (relative to views dir) |
data | object | Variables available in the template |
options.layout | string | false | Override layout or disable with false |
Every req.render() call automatically injects these variables into the template data (you don't need to pass them manually):
| Variable | Type | Description | Usage in template |
|---|---|---|---|
flashes | object | Flash messages by category (success, error, etc.) | [# if flashes.success] |
csrfToken | string | CSRF token value (when csrf guard is enabled) | [= csrfToken] |
csrfField | string | Ready-to-use <input type="hidden"> with CSRF token | [= raw(csrfField)] |
[= raw(csrfField)] inside <form> tags to automatically include the CSRF hidden input. Use raw() because csrfField contains HTML.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,
})
Practical recipes for common use cases in SSR templates.
<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>
[# 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]
<nav>
<a href="/" class="[# if page === 'home']active[/if]">Home</a>
<a href="/about" class="[# if page === 'about']active[/if]">About</a>
</nav>
<!-- 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]
<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>
<!-- 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>
// 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]
All [= expr] expressions are auto-escaped by default. The engine replaces dangerous characters with HTML entities:
| Character | Entity |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
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.Compiled render functions are stored in an LRU (Least Recently Used) cache:
Clear the cache manually (e.g., in development):
app._viewEngine.clearCache()
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:
| Method | Description |
|---|---|
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 |
| Option | Type | Default | Description |
|---|---|---|---|
dir | string | — | Absolute path to views directory |
layout | string | null | Default layout template name |
globals | object | {} | Global variables for all templates |
helpers | object | {} | Custom helper functions |
cacheMax | number | 500 | Maximum LRU cache entries |
Template files are resolved from the views directory:
'home' → views/home.html'admin/dashboard' → views/admin/dashboard.html.html then /index.html'../../etc/passwd' are blocked with an error.Here is a minimal SSR app with layout, nav, homepage, and a dynamic page:
export default {
layout: 'layout',
globals: {
siteName: 'My App',
year: new Date().getFullYear(),
},
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>[= title || siteName]</title>
[= raw(styles)]
</head>
<body>
[> nav]
<main>[= raw(body)]</main>
<footer>© [= year] [= siteName]</footer>
[= raw(scripts)]
</body>
</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>
[# 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>
export default {
routes: {
'GET /': 'home',
},
async home(req) {
return req.render('home', {
title: 'Home',
user: req.user || null,
})
},
}
| Feature | Details |
|---|---|
| Syntax | [= expr], [# if/each/block], [> include], [/close] |
| Compilation | AOT: Source → Tokens → AST → JS Function (cached) |
| Escaping | Auto HTML-escaping on all expressions, raw() to opt-out |
| Layouts | Single-level with body + named blocks |
| Includes | Partials with optional scope isolation via [> file { data }] |
| Pipe Filters | Chainable: value | upper | truncate:50 |
| Built-in Helpers | raw, upper, lower, capitalize, truncate, date, json, pad, plural, currency |
| Custom Helpers | app.addHelper(name, fn) or helpers in settings.js |
| Caching | LRU with configurable max (default 500) |
| Security | Auto-escaping + path traversal protection |
| Dependencies | Zero — built into SpaceNode core |