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
| Engine | Package | Extensions | SSR | Streaming |
|---|---|---|---|---|
| EJS | ejs | .ejs | No | No |
| Pug | pug | .pug, .jade | No | No |
| Handlebars | hbs | .hbs, .handlebars | No | No |
| React | react, react-dom | .tsx, .jsx | Yes | Yes |
| Vue | vue | .vue | Yes | Yes |
| Svelte | svelte | .svelte | Yes | Yes |
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:
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:
- Installed packages in
package.json - View files in common directories (
views/,src/views/) - 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
-
Use presets for quick configuration:
await this.Middleware.render('production'); -
Enable caching in production:
await this.Middleware.render({ cache: true }); -
Use streaming for React/Vue/Svelte to improve TTFB
-
Keep views organized:
views/├── layouts/├── partials/├── pages/└── components/ -
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:
- Check
viewsDirpath is correct - Verify template file exists with correct extension
- 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:
- Install the template engine:
npm install ejs - Verify it's in
dependenciesordevDependencies - Manually specify the engine:
await this.Middleware.render({
engine: "ejs", // Explicitly specify
viewsDir: "views"
});
Variables Not Interpolating
Problem: Template shows <%= user.name %> literally
Solution:
- Check file extension matches engine (
.ejsfor EJS) - Verify engine is configured correctly
- 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:
- Configure
partialsDir:
await this.Middleware.render({
engine: "hbs",
viewsDir: "views",
partialsDir: "views/partials", // Specify partials directory
});
- Verify partial file exists
- Check partial syntax is correct:
SSR Hydration Issues
Problem: Client-side hydration fails
Solution:
- Ensure client bundle is built and served
- Verify hydration script is included:
<script src="/assets/client.js" defer></script>
- Match server and client render output
- Check browser console for hydration errors
Performance Issues
Problem: Slow template rendering
Solutions:
- Enable caching in production:
await this.Middleware.render({
cache: process.env.NODE_ENV === "production"
});
- Use streaming for React/Vue/Svelte:
await this.Middleware.render({
streaming: true,
ssr: { streaming: true }
});
- Precompile templates:
# For EJS
npm run precompile-templates
- 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:
- Enable watch mode:
await this.Middleware.render({
watch: true // or 'auto' for development only
});
- Disable caching in development:
await this.Middleware.render("development");
- Restart server if hot reload fails
- Use the @Render() decorator for cleaner controller code
Support the Project
ExpressoTS is MIT-licensed open source. See the support guide to contribute.