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/view

Access the view service via #services:

services.ts
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.

config/view.ts
import type { ViewConfig } from '@tekir/view'

export default {
  dir: 'resources/views',
  engine: {
    render(template, data) {
      // return HTML string
    }
  }
} satisfies ViewConfig

Register ViewProvider in your kernel:

start/kernel.ts
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.

config/view.ts
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
<!-- 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.

config/view.ts
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
<!-- 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.

config/view.ts
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
<!-- 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.

config/view.ts
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
//- resources/views/pages/home.pug
doctype html
html
  head
    title= title
  body
    h1 Welcome, #{name}!
    if items
      ul
        each item in items
          li= item

React SSR

Server-side render React components with streaming support. Components are imported directly; no file path resolution needed.

config/view.ts
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 ViewConfig
resources/views/pages/Home.tsx
interface 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:

start/routes.ts
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:

config/view.ts
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 ViewConfig

Streaming

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 })