Views
Engine-agnostic server-side rendering. Use Eta, EJS, Handlebars, Pug, React, or any custom template engine.
Introduction
@tekir/view is a thin adapter that connects your routes to any template engine. Implement the ViewEngine interface once in config/view.ts, then use the view service everywhere. The framework doesn't ship a template engine; you pick the one you prefer.
bun add @tekir/viewAccess the view service via #services:
import { service } from '@tekir/core'
import type { View } from '@tekir/view'
export const view = service<View>('view')Configuration
Create config/view.ts. The dir field sets the views directory (default: resources/views). The engine field is your template engine.
import type { ViewConfig } from '@tekir/view'
export default {
dir: 'resources/views',
engine: {
render(template, data) {
// return HTML string
}
}
} satisfies ViewConfigRegister ViewProvider in your kernel:
import type { TekirApp } from '@tekir/core'
import { ViewProvider } from '@tekir/view'
export default function({ app }: TekirApp) {
app.registerAll([ViewProvider])
}ViewEngine Interface
Any object with a render() method that returns HTML is a valid engine. The optional renderStream() enables HTTP streaming.
interface ViewEngine {
// Required: render a template and return HTML
render(template: any, data?: any): Promise<string> | string
// Optional: return a ReadableStream for HTTP streaming
renderStream?(template: any, data?: any): Promise<ReadableStream>
}
interface ViewConfig {
engine: ViewEngine
dir?: string // default: 'resources/views'
}Rendering Views
Call view.render() from any route handler. It returns a Response with Content-Type: text/html.
import { view } from '#services'
// In a route handler: returns a Response with Content-Type: text/html
return view.render('pages/home', { title: 'Welcome', name: 'Alice' })Render Options
return view.render('pages/home', { title: 'Welcome' }, {
stream: true, // use renderStream() if available (default: true)
status: 200, // HTTP status code (default: 200)
headers: { // extra response headers
'Cache-Control': 'no-store'
}
})renderToHTML()
Returns a raw HTML string instead of a Response. Useful for emails, PDFs, or testing.
import { view } from '#services'
// Get raw HTML string: useful for emails, PDFs, testing
const html = await view.renderToHTML('emails/welcome', {
name: 'Alice',
link: 'https://myapp.com/confirm/abc'
})
await mail.to('[email protected]').subject('Welcome').html(html).send()Template Engines
Below are ready-to-use configurations for popular engines. Install the package and paste the config.
Eta
Lightweight, fast, supports async. Syntax similar to EJS but smaller and faster.
import { Eta } from 'eta'
import { join } from 'path'
import type { ViewConfig } from '@tekir/view'
const eta = new Eta({ views: join(process.cwd(), 'resources/views') })
export default {
engine: {
render(template, data) {
return eta.render(template, data)
}
}
} satisfies ViewConfig<!-- resources/views/pages/home.eta -->
<!DOCTYPE html>
<html>
<head><title><%= it.title %></title></head>
<body>
<h1>Welcome, <%= it.name %>!</h1>
<% if (it.items) { %>
<ul>
<% it.items.forEach(item => { %>
<li><%= item %></li>
<% }) %>
</ul>
<% } %>
</body>
</html>EJS
The classic embedded JavaScript template engine. Widely known, large ecosystem.
import ejs from 'ejs'
import { join } from 'path'
import { readFileSync } from 'fs'
import type { ViewConfig } from '@tekir/view'
const viewsDir = join(process.cwd(), 'resources/views')
export default {
engine: {
async render(template, data) {
const filePath = join(viewsDir, template + '.ejs')
const source = readFileSync(filePath, 'utf-8')
return ejs.render(source, data, { filename: filePath })
}
}
} satisfies ViewConfig<!-- resources/views/pages/home.ejs -->
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<h1>Welcome, <%= name %>!</h1>
<% if (items) { %>
<ul>
<% items.forEach(item => { %>
<li><%= item %></li>
<% }) %>
</ul>
<% } %>
</body>
</html>Handlebars
Logic-less templates with helpers and partials. Strict separation of logic and presentation.
import Handlebars from 'handlebars'
import { join } from 'path'
import { readFileSync } from 'fs'
import type { ViewConfig } from '@tekir/view'
const viewsDir = join(process.cwd(), 'resources/views')
export default {
engine: {
render(template, data) {
const filePath = join(viewsDir, template + '.hbs')
const source = readFileSync(filePath, 'utf-8')
return Handlebars.compile(source)(data)
}
}
} satisfies ViewConfig<!-- resources/views/pages/home.hbs -->
<!DOCTYPE html>
<html>
<head><title>{{title}}</title></head>
<body>
<h1>Welcome, {{name}}!</h1>
{{#if items}}
<ul>
{{#each items}}
<li>{{this}}</li>
{{/each}}
</ul>
{{/if}}
</body>
</html>Pug
Indentation-based syntax, no closing tags. Compiles to highly optimized JavaScript.
import pug from 'pug'
import { join } from 'path'
import type { ViewConfig } from '@tekir/view'
const viewsDir = join(process.cwd(), 'resources/views')
export default {
engine: {
render(template, data) {
return pug.renderFile(join(viewsDir, template + '.pug'), data)
}
}
} satisfies ViewConfig//- resources/views/pages/home.pug
doctype html
html
head
title= title
body
h1 Welcome, #{name}!
if items
ul
each item in items
li= itemReact SSR
Server-side render React components with streaming support. Components are imported directly; no file path resolution needed.
import { renderToReadableStream, renderToString } from 'react-dom/server'
import type { ViewConfig } from '@tekir/view'
export default {
engine: {
async renderStream(component, props) {
return renderToReadableStream(component(props))
},
async render(component, props) {
return renderToString(component(props))
}
}
} satisfies ViewConfiginterface Props {
title: string
name: string
}
const Home = ({ title, name }: Props) => {
return (
<html>
<head><title>{title}</title></head>
<body>
<h1>Welcome, {name}!</h1>
</body>
</html>
)
}Usage:
import type { TekirApp } from '@tekir/core'
import { view } from '#services'
import Home from '../resources/views/pages/Home'
import About from '../resources/views/pages/About'
export default function({ router }: TekirApp) {
router.get('/', () => view.render(Home, { title: 'Welcome', name: 'Alice' }))
router.get('/about', () => view.render(About, { title: 'About' }))
}Custom Engine
Any object with a render()method works. Here's a minimal string interpolation engine:
import type { ViewConfig } from '@tekir/view'
import { readFileSync } from 'fs'
import { join } from 'path'
const viewsDir = join(process.cwd(), 'resources/views')
export default {
engine: {
render(template, data = {}) {
let html = readFileSync(join(viewsDir, template + '.html'), 'utf-8')
for (const [key, value] of Object.entries(data)) {
html = html.replaceAll(`{{${key}}}`, String(value))
}
return html
}
}
} satisfies ViewConfigStreaming
When your engine implements renderStream(), view.render() uses it automatically. The browser receives HTML chunks as they are produced, improving time-to-first-byte. Currently only React SSR supports this via renderToReadableStream.
import { view } from '#services'
// Streaming is automatic when the engine implements renderStream().
// The browser receives HTML chunks as they are produced: lower TTFB.
return view.render(HeavyPage, props)
// Force string render:
return view.render(HeavyPage, props, { stream: false })