Preview, tweak and print your HTML templates from the browser.
An Express add-on that serves your email, PDF and page templates behind a floating options panel. Declare the parameters a template understands, and the panel lets you flip through every variant with typed, URL-backed controls. One click renders the same HTML to PDF.
Quick start · Defining templates · The options panel · PDF · Routes · API · AGENTS.md
Transactional emails, invoices, vouchers and reports come in dozens of variants: locale, formal or informal tone, with or without a note, paid or unpaid, one ticket or four. Checking every variant by editing query strings is slow, and nothing tells you which parameters a template actually reads.
template-server turns that into a schema. Each template declares its options once, the parsed values arrive fully typed in the render function, and the browser gets a panel that knows about every option: switches for booleans, steppers for numbers, chips for enums, a text box for strings.
- Typed options with
option.enum,option.oneOf,option.boolean,option.number,option.stringandoption.text. Defaults are applied server-side, soparams.localeis aLocale, neverstring | undefined. - Shared option sets composed with spread. Define your branding options once and reuse them in thirty templates.
- A shared context for translators, storage clients or fixtures, passed to every render instead of module globals.
- Groups and kinds. Templates are grouped on the index page and tagged as
email,pdforpage. The panel adapts: emails get a mobile-width preview, PDFs get a one-click render. - Isolated UI. The panel lives in a Shadow DOM, so your template CSS cannot break it and it cannot leak into your template. It is hidden in print and never appears in a PDF.
- Zero build step for the client. Plain JavaScript and CSS, served from the package.
npm install template-server express
# puppeteer is an optional peer dependency, only needed for /pdf
npm install --save-dev puppeteerimport { createTemplateServer, defineTemplate, option } from 'template-server';
const invoice = defineTemplate({
title: 'Invoice',
kind: 'pdf',
options: {
currency: option.oneOf(['EUR', 'USD', 'GBP']),
status: option.oneOf(['draft', 'sent', 'paid']),
items: option.number({ min: 1, max: 20, default: 3 }),
showTax: option.boolean({ default: true }),
},
render: ({ currency, status, items, showTax }) => `
<!DOCTYPE html><html><body>
<h1>Invoice · ${status.toUpperCase()}</h1>
${Array.from({ length: items }, (_, i) => `<p>Item ${i + 1}: ${currency} 100.00</p>`).join('')}
${showTax ? '<p>VAT 20%</p>' : ''}
</body></html>`,
});
createTemplateServer({ templates: { invoice } }).listen(3020);Open http://localhost:3020, pick the template, and use the panel in the bottom-right corner. Every change updates the query string in place, so any variant is a shareable link.
Every option describes one query parameter: how it is parsed, how it is serialized, what the panel renders for it, and what the value is when the URL does not set it.
| Builder | Type inside render |
Panel control | URL form | Default |
|---|---|---|---|---|
option.enum(MyEnum) |
MyEnum |
chips | member value | first member |
option.oneOf(['a', 'b']) |
'a' | 'b' |
chips | the literal | first value |
option.boolean() |
boolean |
switch | 1 / 0 |
false |
option.number({ min, max, step }) |
number |
stepper | number | default, else min, else 0 |
option.string({ placeholder }) |
string |
text input | text | '' |
option.text({ placeholder, rows }) |
string |
textarea | text | '' |
All builders accept label, description and default. Invalid URL values fall back to the default rather than failing.
Declare option sets once with defineOptions and spread them into templates. Infer gives you the parameter type, so fixtures can take typed params too:
import { defineOptions, option, type Infer } from 'template-server';
export const branding = defineOptions({
restaurant: option.oneOf(['Das Bootshaus', 'Tante Liesl']),
locale: option.enum(SupportedLocale, { default: SupportedLocale.DE }),
formal: option.boolean(),
});
export const reservation = defineOptions({
note: option.boolean({ label: 'Guest note' }),
price: option.oneOf(['none', 'full', 'deposit']),
payment: option.enum(PaymentType),
});
export type BrandingParams = Infer<typeof branding>;
function createRestaurant(params: BrandingParams) { /* params.formal is a boolean */ }
const confirmation = defineTemplate({
group: 'Reservation emails',
kind: 'email',
options: { ...branding, ...reservation, text: option.boolean() },
render: (params, ctx: AppContext) => {
const restaurant = createRestaurant(params);
return renderEmail(ConfirmationTemplate({ restaurant, locale: params.locale, translate: ctx.translate }));
},
});Anything your templates need at render time goes into the server context. It is merged with { template, baseUrl, query } and passed as the second argument to render. Type ctx in your render functions and the compiler checks it against what the server provides.
interface AppContext { translate: TranslateFn; storage: Storage }
createTemplateServer<AppContext>({
templates: { confirmation, reminder, voucher },
context: async (req) => ({ translate: await getTranslator(req), storage }),
pdf: { printBackground: true },
title: 'Molzait templates',
});| Field | Effect |
|---|---|
title, description |
Shown on the index page and in the panel header. |
group |
Templates with the same group are listed together on the index page. |
kind: 'email' |
Panel gets a mobile-width preview toggle (375 px). No PDF action. |
kind: 'pdf' |
Panel and index get an "Open as PDF" action. |
kind: 'page' |
Default. Plain preview. |
pdf |
Puppeteer PDFOptions for this template, merged over the server defaults. |
The original form still works and can be mixed with definitions:
templates: {
legacy: (query, options) => {
options.theme = ['light', 'dark'];
return `<html>…${query.theme}…</html>`;
},
}Legacy templates render chips only, have no defaults, and receive the raw query.
- Collapsed pill with a badge counting the parameters currently set. Press o to toggle, Esc to collapse.
- Labels left, controls right. Enum values as chips, booleans as switches, numbers as steppers, strings as text inputs, multi-line text as textareas.
- The value in effect is always visible: an outlined chip means "default, not in the URL", a solid chip means "set". Clicking a set chip removes the parameter again. Values in the URL that are not in the list show as a dashed custom chip.
- Filter box for templates with many options, plus a footer with the live query string, a copy-link button and Reset.
- Navigation uses
location.replace, so flipping options never pollutes the browser history. - Movable to the left or right edge. Collapsed state, side and preview width are remembered per template.
- Rendered inside a Shadow DOM and hidden under
@media print.
GET /:template/pdf renders the same HTML with headless Chromium via Puppeteer and streams an A4 PDF with printBackground: true. Options are merged in this order: built-in defaults, server pdf, template pdf.
Puppeteer is a peer dependency and is loaded lazily, so projects that never render PDFs do not need it installed.
| Route | Description |
|---|---|
GET / |
Index page: grouped, searchable list of templates with kind badges. Press / to search. |
GET /:template |
Renders the template with the options panel injected. |
GET /:template/pdf |
Renders the template to PDF. |
GET /public/* |
Panel assets (options.js, options.css), versioned for cache busting. |
Template names must not contain /. Use group for organisation instead.
import express from 'express';
import { attachTemplateServer } from 'template-server';
const app = express();
attachTemplateServer(app, { templates, context });Asset and link URLs respect req.baseUrl, so the server also works when mounted as a sub-app.
createTemplateServer(config | routes, pdfDefaults?) // returns a new Express app
attachTemplateServer(app, config | routes, pdfDefaults?)
defineTemplate({ title?, description?, group?, kind?, options?, pdf?, render })
defineOptions(schema) // identity, keeps literal types
option.enum | option.oneOf | option.boolean | option.number | option.string | option.text
type Infer<typeof schema> // parsed params type
type TemplateContext<C> // C & { template, baseUrl, query }
parseParams(schema, query) // the parser the server usespnpm install
npm start # example server on http://localhost:3020 (PORT overrides)
npm run build # compile to dist/The example in src/start.ts shows shared option sets, a typed context, email and PDF kinds, and a legacy template side by side. See AGENTS.md for an in-depth description of the architecture written for AI coding agents.
MIT