Skip to main content
Version: 4.0.0-preview

Render engine

ExpressoTS v4 introduces a unified render engine system that supports traditional template engines (EJS, Pug, Handlebars) and modern frameworks (React, Vue, Svelte) with a consistent API.

Quick start

The simplest way to enable rendering is with auto-detection:

export class App extends AppExpress {
async configureServices(): Promise<void> {
// Auto-detects installed engine
await this.Middleware.render();
}
}

ExpressoTS will automatically detect which template engine you have installed and configure it with sensible defaults.

Supported engines

EnginePackageExtensionsSSRStreaming
EJSejs.ejsNoNo
Pugpug.pug, .jadeNoNo
Handlebarshbs.hbs, .handlebarsNoNo
Reactreact, react-dom.tsx, .jsxYesYes
Vuevue.vueYesYes
Sveltesvelte.svelteYesYes

Configuration

Using presets

Presets provide pre-configured settings for common scenarios:

// Development: Hot reload, no caching, debug mode
await this.Middleware.render('development');

// Production: Caching enabled, streaming, no debug
await this.Middleware.render('production');

// SSR: Optimized for server-side rendering with hydration
await this.Middleware.render('ssr');

Full configuration

await this.Middleware.render({
// Engine selection ('auto' for auto-detection)
engine: 'react',

// Directories
viewsDir: 'src/views',
layoutsDir: 'src/views/layouts',
partialsDir: 'src/views/partials',

// Performance
cache: 'auto', // true in production, false in development
streaming: true, // Enable streaming render

// Development
watch: 'auto', // Hot reload in development
debug: true, // Enable /__views debug endpoint

// SSR configuration (for React/Vue/Svelte)
ssr: {
hydrate: true, // Enable client-side hydration
streaming: true, // Use streaming render
preload: true, // Preload data
},

// Client bundle directory (for React/Vue/Svelte)
clientBundleDir: 'public/assets',
});

Traditional engines

EJS

Embedded JavaScript templates with clean syntax:

await this.Middleware.render({
engine: 'ejs',
viewsDir: 'views',
ejsOptions: {
serverOptions: {
cache: true,
compileDebug: false,
}
}
});

Template example:

<!-- views/index.ejs -->
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<h1>Welcome, <%= user.name %>!</h1>
<ul>
<% for (const item of items) { %>
<li><%= item %></li>
<% } %>
</ul>
</body>
</html>

Pug

Clean, whitespace-sensitive template syntax:

await this.Middleware.render({
engine: 'pug',
viewsDir: 'views',
});

Template example:

//- views/index.pug
doctype html
html
head
title= title
body
h1 Welcome, #{user.name}!
ul
each item in items
li= item

Handlebars

Logic-less templates with partials support:

await this.Middleware.render({
engine: 'hbs',
viewsDir: 'views',
partialsDir: 'views/partials',
hbsOptions: {
helpers: {
formatDate: (date) => new Date(date).toLocaleDateString(),
}
}
});

Template example:

{{!-- views/index.hbs --}}
<!DOCTYPE html>
<html>
<head><title>{{title}}</title></head>
<body>
<h1>Welcome, {{user.name}}!</h1>
{{> header}}
<ul>
{{#each items}}
<li>{{this}}</li>
{{/each}}
</ul>
</body>
</html>

React SSR

ExpressoTS v4 supports React Server-Side Rendering with hydration:

Setup

await this.Middleware.render({
engine: 'react',
viewsDir: 'src/views',
clientBundleDir: 'public/assets',
ssr: {
hydrate: true,
streaming: true,
}
});

Create React components

// src/views/pages/Home.tsx
import React from 'react';

interface HomeProps {
title: string;
user: { name: string };
items: string[];
}

export default function Home({ title, user, items }: HomeProps) {
return (
<html>
<head><title>{title}</title></head>
<body>
<h1>Welcome, {user.name}!</h1>
<ul>
{items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</body>
</html>
);
}

Render in controllers

@controller("/")
export class HomeController {
@Get("/")
async home(@response() res: Response): Promise<void> {
res.render('pages/Home', {
title: 'Welcome',
user: { name: 'John' },
items: ['Item 1', 'Item 2', 'Item 3']
});
}
}

Rendering views

Using res.render()

The standard Express way to render views:

@Get("/")
async home(@response() res: Response): Promise<void> {
res.render('index', {
title: 'Home',
user: { name: 'John' }
});
}

Using @Render() decorator

A cleaner approach with decorators:

@Get("/")
@Render("index")
async home(): Promise<object> {
return {
title: 'Home',
user: { name: 'John' }
};
}

// With default data
@Get("/about")
@Render("about", { title: "About Us" })
async about(): Promise<void> {}

Using RenderService directly

For advanced use cases:

@controller("/api")
export class ApiController {
@Get("/preview/:template")
async preview(
@Param("template") template: string,
@response() res: Response
): Promise<void> {
const renderService = (this.Middleware as any).getRenderService();
const html = await renderService.render(template, { preview: true });
res.send(html);
}
}

Advanced features

Auto-detection

ExpressoTS auto-detects the best engine based on:

  1. Installed packages in package.json
  2. View files in common directories (views/, src/views/)
  3. Priority: React > Vue > Svelte > EJS > Pug > Handlebars

Hot reload

Automatically enabled in development:

await this.Middleware.render({
watch: true, // or 'auto' for environment-based
});

Changes to view files are immediately reflected without server restart.

Streaming render

For React/Vue/Svelte, enable streaming for better Time-to-First-Byte:

await this.Middleware.render({
streaming: true,
ssr: { streaming: true }
});

View debugger

Enable the debug endpoint in development:

await this.Middleware.render({
debug: true,
});

Access /__views to see:

  • Registered engines
  • Available views
  • Configuration
  • Render metrics

Type generation

Generate TypeScript types for your views:

// In postServerInitialization
const renderService = this.Middleware.getRenderService();
if (renderService) {
await renderService.enableTypeGeneration();
}

This creates views.generated.d.ts with types for all your views.

Migration from v3

The old setEngine() method is deprecated. Here's how to migrate:

// Before (deprecated)
this.setEngine(RenderEngine.Engine.EJS, {
viewsDir: 'views'
});

// After (recommended)
await this.Middleware.render({
engine: 'ejs',
viewsDir: 'views'
});

// Or with auto-detection
await this.Middleware.render();

Best practices

  1. Use presets for quick configuration:

    await this.Middleware.render('production');
  2. Enable caching in production:

    await this.Middleware.render({ cache: true });
  3. Use streaming for React/Vue/Svelte to improve TTFB

  4. Keep views organized:

    views/
    ├── layouts/
    ├── partials/
    ├── pages/
    └── components/
  5. Use the @Render() decorator for cleaner controller code

Testing Render Engine

Unit Testing View Rendering

Test view rendering with mocked templates:

import { Response } from "express";

describe("HomeController", () => {
let controller: HomeController;
let mockResponse: jest.Mocked<Response>;

beforeEach(() => {
mockResponse = {
render: jest.fn(),
} as any;

controller = new HomeController();
});

it("should render home view with correct data", async () => {
await controller.home(mockResponse);

expect(mockResponse.render).toHaveBeenCalledWith(
"index",
expect.objectContaining({
title: "Home",
user: expect.objectContaining({ name: "John" })
})
);
});

it("should pass all required variables to template", async () => {
await controller.userProfile(mockResponse);

expect(mockResponse.render).toHaveBeenCalledWith(
"profile",
expect.objectContaining({
user: expect.any(Object),
stats: expect.any(Object),
activity: expect.any(Array)
})
);
});
});

Testing @Render() Decorator

Test the decorator pattern:

describe("@Render() Decorator", () => {
it("should return data that gets passed to template", async () => {
@controller("/test")
class TestController {
@Get("/")
@Render("index")
async home(): Promise<object> {
return {
title: "Test",
data: "example"
};
}
}

const controller = new TestController();
const result = await controller.home();

expect(result).toEqual({
title: "Test",
data: "example"
});
});

it("should use default data when handler returns nothing", async () => {
@controller("/test")
class TestController {
@Get("/about")
@Render("about", { title: "About Us", company: "ExpressoTS" })
async about(): Promise<void> {
// Returns nothing - uses default data
}
}

// The @Render decorator handles passing default data to template
expect(true).toBe(true);
});
});

E2E Testing with Template Engines

Test actual template rendering:

import { bootstrap } from "@expressots/core";
import { App } from "./app";

describe("Render Engine E2E", () => {
let app: any;

beforeAll(async () => {
app = await bootstrap(App, { port: 0 });
});

afterAll(async () => {
await app.close();
});

describe("EJS Templates", () => {
it("should render EJS template correctly", async () => {
const response = await fetch(`http://localhost:${app.port}/`);
const html = await response.text();

expect(html).toContain("<title>Welcome</title>");
expect(html).toContain("Welcome, John!");
expect(html).toContain("<li>Item 1</li>");
});

it("should interpolate variables correctly", async () => {
const response = await fetch(
`http://localhost:${app.port}/profile/user-123`
);
const html = await response.text();

expect(html).toContain("user-123");
});

it("should render loops correctly", async () => {
const response = await fetch(`http://localhost:${app.port}/items`);
const html = await response.text();

expect(html).toContain("<li>Alpha</li>");
expect(html).toContain("<li>Beta</li>");
expect(html).toContain("<li>Gamma</li>");
});
});

describe("Pug Templates", () => {
it("should render Pug template correctly", async () => {
const response = await fetch(`http://localhost:${app.port}/pug`);
const html = await response.text();

expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("<h1>Welcome, Jane!</h1>");
});

it("should handle Pug conditionals", async () => {
const response = await fetch(
`http://localhost:${app.port}/pug/conditional`
);
const html = await response.text();

expect(html).toContain("User is logged in");
});
});

describe("Handlebars Templates", () => {
it("should render Handlebars template correctly", async () => {
const response = await fetch(`http://localhost:${app.port}/hbs`);
const html = await response.text();

expect(html).toContain("<title>Handlebars Page</title>");
});

it("should use Handlebars helpers", async () => {
const response = await fetch(`http://localhost:${app.port}/hbs/date`);
const html = await response.text();

expect(html).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); // Date format
});

it("should render Handlebars partials", async () => {
const response = await fetch(`http://localhost:${app.port}/hbs`);
const html = await response.text();

expect(html).toContain("<nav>"); // From header partial
expect(html).toContain("<a href=\"/\">Home</a>");
});
});

describe("Content Negotiation", () => {
it("should return JSON when Accept header is application/json", async () => {
const response = await fetch(`http://localhost:${app.port}/data`, {
headers: { Accept: "application/json" }
});

expect(response.headers.get("content-type")).toContain("application/json");

const data = await response.json();
expect(data).toHaveProperty("users");
});

it("should return HTML when Accept header is text/html", async () => {
const response = await fetch(`http://localhost:${app.port}/data`, {
headers: { Accept: "text/html" }
});

expect(response.headers.get("content-type")).toContain("text/html");

const html = await response.text();
expect(html).toContain("<!DOCTYPE html>");
});
});

describe("Performance", () => {
it("should render templates quickly", async () => {
const start = Date.now();
await fetch(`http://localhost:${app.port}/`);
const duration = Date.now() - start;

expect(duration).toBeLessThan(100); // Should render in < 100ms
});

it("should benefit from caching in production", async () => {
const times: number[] = [];

for (let i = 0; i < 5; i++) {
const start = Date.now();
await fetch(`http://localhost:${app.port}/`);
times.push(Date.now() - start);
}

// Later requests should be faster (cached templates)
const avgFirst2 = (times[0] + times[1]) / 2;
const avgLast2 = (times[3] + times[4]) / 2;

expect(avgLast2).toBeLessThanOrEqual(avgFirst2);
});
});
});

Testing Render Configuration

Test different engine configurations:

describe("Render Configuration", () => {
it("should auto-detect installed engine", async () => {
const app = new App();
await app.configureServices();

const renderService = app.Middleware.getRenderService();
const activeEngine = renderService?.getActiveEngine();

expect(activeEngine).toBeDefined();
expect(["ejs", "pug", "hbs"]).toContain(activeEngine?.name);
});

it("should configure EJS with custom options", async () => {
await app.Middleware.render({
engine: "ejs",
viewsDir: "custom-views",
ejsOptions: {
serverOptions: {
cache: true,
compileDebug: false
}
}
});

expect(app.Middleware.getRenderService()).toBeDefined();
});

it("should use development preset in development", async () => {
process.env.NODE_ENV = "development";

await app.Middleware.render("development");

const renderService = app.Middleware.getRenderService();
expect(renderService).toBeDefined();
});
});

SSR Examples

React SSR

Full React Server-Side Rendering setup:

// app.ts
export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Middleware.parse();

await this.Middleware.render({
engine: "react",
viewsDir: "src/views",
clientBundleDir: "public/assets",
cache: process.env.NODE_ENV === "production",
ssr: {
hydrate: true,
streaming: true,
preload: true,
}
});
}
}
// src/views/pages/Home.tsx
import React from "react";

interface HomeProps {
title: string;
user: { name: string; avatar: string };
posts: Array<{ id: string; title: string; excerpt: string }>;
}

export default function Home({ title, user, posts }: HomeProps) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>{title}</title>
<script src="/assets/client.js" defer />
</head>
<body>
<header>
<h1>Welcome, {user.name}!</h1>
<img src={user.avatar} alt={user.name} />
</header>

<main>
<h2>Recent Posts</h2>
{posts.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</main>
</body>
</html>
);
}
// controllers/home.controller.ts
@controller("/")
export class HomeController {
@Get("/")
async home(@response() res: Response): Promise<void> {
const user = await this.userService.getCurrentUser();
const posts = await this.postService.getRecent(10);

res.render("pages/Home", {
title: "Home - ExpressoTS",
user: {
name: user.name,
avatar: user.avatarUrl
},
posts: posts.map(p => ({
id: p.id,
title: p.title,
excerpt: p.excerpt
}))
});
}
}

Vue SSR

// app.ts
await this.Middleware.render({
engine: "vue",
viewsDir: "src/views",
clientBundleDir: "public/assets",
ssr: {
hydrate: true,
streaming: false, // Vue streaming is experimental
}
});
<!-- src/views/Home.vue -->
<template>
<div>
<h1>{{ title }}</h1>
<div v-for="item in items" :key="item.id">
<h2>{{ item.title }}</h2>
<p>{{ item.content }}</p>
</div>
</div>
</template>

<script setup lang="ts">
interface Props {
title: string;
items: Array<{ id: string; title: string; content: string }>;
}

defineProps<Props>();
</script>

Svelte SSR

// app.ts
await this.Middleware.render({
engine: "svelte",
viewsDir: "src/views",
clientBundleDir: "public/assets",
ssr: {
hydrate: true,
streaming: true,
}
});
<!-- src/views/Home.svelte -->
<script lang="ts">
export let title: string;
export let items: Array<{ id: string; name: string }>;
</script>

<main>
<h1>{title}</h1>
<ul>
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
</ul>
</main>

<style>
main {
max-width: 1200px;
margin: 0 auto;
}
</style>

Troubleshooting

Template Not Found

Problem: Error: Failed to lookup view "index" in views directory

Solution:

  1. Check viewsDir path is correct
  2. Verify template file exists with correct extension
  3. Check file permissions
await this.Middleware.render({
engine: "ejs",
viewsDir: path.join(__dirname, "views"), // Use absolute path
});

Engine Not Detected

Problem: Error: No template engine found

Solution:

  1. Install the template engine: npm install ejs
  2. Verify it's in dependencies or devDependencies
  3. Manually specify the engine:
await this.Middleware.render({
engine: "ejs", // Explicitly specify
viewsDir: "views"
});

Variables Not Interpolating

Problem: Template shows <%= user.name %> literally

Solution:

  1. Check file extension matches engine (.ejs for EJS)
  2. Verify engine is configured correctly
  3. Check variable is passed to res.render():
res.render("index", { user: { name: "John" } }); // ✅ Correct
res.render("index"); // ❌ Missing data

Partials Not Loading

Problem: Error: Partial "header" not found

Solution:

  1. Configure partialsDir:
await this.Middleware.render({
engine: "hbs",
viewsDir: "views",
partialsDir: "views/partials", // Specify partials directory
});
  1. Verify partial file exists
  2. Check partial syntax is correct:
{{> header}} <!-- ✅ Correct -->
{{> header.hbs}} <!-- ❌ Don't include extension -->

SSR Hydration Issues

Problem: Client-side hydration fails

Solution:

  1. Ensure client bundle is built and served
  2. Verify hydration script is included:
<script src="/assets/client.js" defer></script>
  1. Match server and client render output
  2. Check browser console for hydration errors

Performance Issues

Problem: Slow template rendering

Solutions:

  1. Enable caching in production:
await this.Middleware.render({
cache: process.env.NODE_ENV === "production"
});
  1. Use streaming for React/Vue/Svelte:
await this.Middleware.render({
streaming: true,
ssr: { streaming: true }
});
  1. Precompile templates:
# For EJS
npm run precompile-templates
  1. Minimize template complexity:
  • Avoid deep nesting
  • Reduce logic in templates
  • Move complex operations to controllers

Hot Reload Not Working

Problem: Changes to templates not reflected

Solution:

  1. Enable watch mode:
await this.Middleware.render({
watch: true // or 'auto' for development only
});
  1. Disable caching in development:
await this.Middleware.render("development");
  1. Restart server if hot reload fails

  1. Use the @Render() decorator for cleaner controller code

Support the Project

ExpressoTS is MIT-licensed open source. See the support guide to contribute.