Skip to content
beta

Template Engine

Harpia offers flexible template rendering with support for both third-party engines (like EJS) and its own high-performance native engine designed for modern web applications.


import ejs from "ejs";
import path from "node:path";
import type { Harpia } from "harpia";
export const ejsEngine = {
configure: (app: Harpia) => {
app.engine.set(ejsEngine);
},
render: async (view: string, data: Record<string, any>) => {
const filePath = path.resolve(process.cwd(), "src/views", `${view}.ejs`);
return await ejs.renderFile(filePath, data);
}
};
import harpia from "harpia";
import { ejsEngine } from "./config/ejs";
const app = harpia();
ejsEngine.configure(app);
app.get("/books", async (req, res) => {
await res.render("home", { title: "Books", books: [] });
});
app.listen...

src/views/home.ejs:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
</head>
<body>
<h1>Welcome to <%= title %></h1>
<ul>
<% books.forEach(book => { %>
<li><%= book.title %></li>
<% }); %>
</ul>
</body>
</html>

The built-in engine provides a clean, HTML-based syntax with support for layouts, blocks, components, and plugins.

src/config/template-engine.ts
import path from "node:path";
import { TemplateEngine } from "harpia/template-engine";
const baseDir = process.cwd();
export const engine = new TemplateEngine({
viewName: "page",
useModules: false,
fileExtension: ".html", // The default is `.html`, but you can use `.txt`, `.hml`, or any other.
path: {
views: path.join(baseDir, "src", "views"),
layouts: path.join(baseDir, "src", "layouts"), // optional
components: path.join(baseDir, "src", "components"), // optional
},
});
// Register custom plugins
engine.registerPlugin("uppercase", (str: string) => str.toUpperCase());
engine.registerPlugin("formatDate", (date: Date) => date.toLocaleDateString());
engine.registerPlugin("currency", (value: number) => `$${value.toFixed(2)}`);
import harpia from "harpia";
import { engine } from "./config/template-engine";
const app = harpia();
engine.configure(app);
app.get("/products", async (req, res) => {
await res.render("products", {
title: "Our Products",
products: [
{ name: "Product A", price: 29.99 },
{ name: "Product B", price: 39.99 }
]
});
});

If you want to use the Security Header Protection:

import { harpia } from "harpia";
import { shield } from "./shield";
import { engine } from "./template-engine";
const app = harpia();
// Apply security headers middleware
app.use(shield.middleware(app));
// Setup template engine
engine.configure(app, shield.instance);
// Routes
app.get("/books", async (req, res) => {
await res.render("home", { title: "Books" });
});
app.listen...

If using modular routing, set useModules: true:

src/config/template-engine.ts
import path from "node:path";
import { TemplateEngine } from "harpia/template-engine";
export const engine = new TemplateEngine({
viewName: "page",
useModules: true,
fileExtension: ".html",
path: {
views: path.join(process.cwd(), "modules", "**", "pages"),
layouts: path.join(process.cwd(), "resources", "layouts"),
components: path.join(process.cwd(), "resources", "components"),
},
});
app.get("/users", async (req, res) => {
await res.module("users").render("profile", {
user: { name: "John", email: "john@example.com" }
});
});

Render templates anywhere in your application:

app.get("/", async (req, res) => {
const content = await engine.generate(
"app/services/mailer/templates/account-created",
{ data }
);
return res.html(content);
});

<h1>{{ title }}</h1>
<p>Welcome, {{ user.name }}!</p>
<p>Price: {{ currency(product.price) }}</p>
@set welcomeMessage = "Hello, World!" @endset
<p>{{ welcomeMessage }}</p>
@if user.isAdmin
<div class="admin-panel">
<button>Edit</button>
<button>Delete</button>
</div>
@elseif user.isEditor
<button>Edit</button>
@else
<p>Regular user</p>
@endif
<!-- Array iteration -->
@for product in products
<div class="product">
<h3>{{ product.name }}</h3>
<p>Price: {{ currency(product.price) }}</p>
</div>
@endfor
<!-- Object iteration -->
@for [key, value] in settings
<div class="setting">
<span class="key">{{ key }}:</span>
<span class="value">{{ value }}</span>
</div>
@endfor

Layout (layouts/default.html):

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }} - My App</title>
</head>
<body>
<header>
<nav>...</nav>
</header>
<main>
@yield("content")
</main>
<footer>
@yield("footer")
</footer>
</body>
</html>

Page (views/products.html):

@layout("default", { title: "Products" })
@block("content")
<h1>Our Products</h1>
@for product in products
@component("product-card", product)
@endfor
@endblock
@block("footer")
<p>Contact us for more information!</p>
@endblock

Component (components/product-card.html):

<div class="card">
<h3>{{ name }}</h3>
<p class="price">{{ currency(price) }}</p>
<button>Add to Cart</button>
</div>

Usage:

@component("product-card", {
name: "Premium Widget",
price: 99.99
})
@import("shared/header", { title: "Page Title" })
<div class="content">
<!-- page content -->
</div>
@import("shared/footer")
## This is a single-line comment
##
This is a multi-line comment
that won't appear in the output
##

// Register plugins
engine.registerPlugin("equals", (a: any, b: any) => a === b);
engine.registerPlugin("greaterThan", (a: number, b: number) => a > b);
engine.registerPlugin("and", (...args: boolean[]) => args.every(Boolean));
@if and(equals(user.role, "admin"), greaterThan(user.experience, 2))
<div class="advanced-controls">
<button>Advanced Settings</button>
<button>Export Data</button>
</div>
@endif
<p>{{ uppercase(trim(user.name)) }}</p>
<p>{{ currency(calculateDiscount(product.price, user.discount)) }}</p>
<div>
{{ raw(htmlContent) }}
</div>

When using the Shield module for security headers, the template engine automatically registers a generateNonce plugin. This plugin generates a unique nonce for each request, which you can use to secure inline scripts and styles for Content Security Policy (CSP).

@set nonce = generateNonce() @endset
<script nonce="{{ nonce }}">
console.log("This script is CSP-compliant. Count: 1");
</script>
<script nonce="{{ nonce }}">
console.log("This script is CSP-compliant. Count: 2");
</script>

The generateNonce plugin is only available when the Shield instance is passed to the engine.configure method. See the Shield section for setup instructions.


src/
views/
home/
page.html
products/
page.html
layouts/
default.html
admin.html
components/
header.html
product-card.html
modules/
users/
pages/
profile/
page.html
settings/
page.html
products/
pages/
listing/
page.html
detail/
page.html
resources/
layouts/
default.html
components/
navigation.html
footer.html

  1. Use layouts for consistent page structure
  2. Create reusable components for common UI elements
  3. Register plugins for data transformation logic
  4. Use the modular structure for large applications
  5. Keep templates focused on presentation logic

The native Harpia template engine provides a clean, intuitive syntax while maintaining powerful features for building dynamic web applications.