Converting an HTML email template using Lit as a server-side template engine.
This project demonstrates how to use lit and @lit-labs/ssr to define an HTML email template as a set of composable tagged-template-literal functions and render it to a clean HTML string in Node.js – no browser required.
| Concern | Approach |
|---|---|
| Template authoring | html tagged-template literals from lit |
| Server-side rendering | @lit-labs/ssr + collectResultSync |
| Hydration markers | Stripped from output (not needed for email) |
<head> / <title> |
Built as plain JS template string in the renderer (Lit SSR cannot process expressions inside raw-text elements like <title>) |
| Styling | Single EMAIL_STYLES constant in renderer.ts, injected into the <style> block |
src/
├── index.ts Entry point – renders all templates to dist/
├── renderer.ts renderToString / hackernoonRenderToString / nomoretogoRenderToString / mailchimpRenderToString
├── types.ts Shared TypeScript interfaces
├── scripts/
│ ├── render-hackernoon.ts Standalone script → generated/hackernoon-email.html
│ ├── render-nomoretogo.ts Standalone script → generated/nomoretogo-email.html
│ ├── render-mailchimp.ts Standalone script → generated/mailchimp-email.html
│ └── content/
│ ├── hackernoon-data.ts Sample data for the Hacker Noon template
│ ├── nomoretogo-data.ts Sample data for the No More To-Go template
│ └── mailchimp-data.ts Sample data for the Mailchimp-style template
└── templates/
├── newsletter.ts Generic newsletter Lit body template
├── hackernoon-email.ts Re-export shim → hackernoon/index.ts
├── nomoretogo-email.ts Re-export shim → nomoretogo/index.ts
├── mailchimp-email.ts Re-export shim → mailchimp/index.ts
├── hackernoon/ Modular Hacker Noon template
│ ├── index.ts hackernoonEmailTemplate() composer
│ ├── types.ts Re-exports HackernoonEmailData
│ ├── constants.ts Brand URLs, meme base URL, sponsor constants
│ └── sections/
│ ├── logo.section.ts Top logo banner (templatePreheader)
│ ├── header.section.ts Sponsor card + article body (templateHeader)
│ ├── body.section.ts Closing divider + bottom logo (templateBody)
│ └── footer.section.ts Social icons + copyright (templateFooter)
├── nomoretogo/ Modular No More To-Go template
│ ├── index.ts nomoretogoEmailTemplate() composer
│ ├── types.ts Re-exports NomoretogoEmailData / RecipeItem
│ ├── constants.ts BASE_IMAGE URL
│ └── sections/
│ ├── logo.section.ts
│ ├── nav.section.ts
│ ├── intro.section.ts
│ ├── recipe-grid.section.ts
│ ├── recipe-grid/recipe-row.ts
│ ├── cta.section.ts
│ ├── prep-info.section.ts
│ ├── community.section.ts
│ ├── amazon.section.ts
│ └── footer.section.ts
└── mailchimp/ Modular Mailchimp-style product email template
├── index.ts mailchimpEmailTemplate() composer
├── types.ts Re-exports MailchimpEmailData / ProductItem / FooterColumn
├── constants.ts Placeholder image base URL
└── sections/
├── preheader.section.ts Preview text + view-in-browser link
├── branding.section.ts Dark top-bar with brand name + nav links
├── image.section.ts Full-width hero image
├── text.section.ts H1 heading + body paragraph
├── product-row.section.ts 2-column product card rows
├── product-row/
│ └── product-card.ts Single product card (image, meta, price, CTA)
├── footer.section.ts Three-column dark footer
└── disclaimer.section.ts Company info + unsubscribe link
hackernoon.html Original static HTML reference (Hacker Noon)
nomoretogo.html Original static HTML reference (No More To-Go)
email-template-mailchimp (1).html Original static HTML reference (Mailchimp-style)
npm install
npm run render # builds TypeScript then renders all templates to dist/
npm run render:hackernoon # renders only the Hacker Noon template
npm run render:template # renders only the No More To-Go template
npm run render:mailchimp # renders only the Mailchimp-style template| Command | Output file | Template |
|---|---|---|
npm run render |
dist/rendered-email.html |
Generic newsletter |
npm run render |
dist/rendered-hackernoon.html |
Hacker Noon newsletter |
npm run render |
dist/rendered-nomoretogo.html |
No More To-Go newsletter |
npm run render:hackernoon |
generated/hackernoon-email.html |
Hacker Noon newsletter |
npm run render:template |
generated/nomoretogo-email.html |
No More To-Go newsletter |
npm run render:mailchimp |
generated/mailchimp-email.html |
Mailchimp-style product email |
Open any of the output files in a browser to preview the email.
CI: A GitHub Actions workflow (
.github/workflows/render-email-template.yml) runs on every push and pull request. It builds the project, renders both the Hacker Noon and No More To-Go templates, and uploads the resulting HTML files as arendered-email-htmlartifact.
Ported from hackernoon.html. The template is structured as a modular folder (src/templates/hackernoon/) where each Mailchimp template zone is a separate section file. The top-level hackernoon-email.ts is a backward-compatible re-export shim so existing imports keep working.
import '@lit-labs/ssr/lib/install-global-dom-shim.js'; // must come first
import { hackernoonEmailTemplate } from './templates/hackernoon-email.js';
import { hackernoonRenderToString } from './renderer.js';
import type { HackernoonEmailData } from './types.js';
const data: HackernoonEmailData = {
title: 'The Secrets of High-Performing DevOps Teams',
preheaderText: 'A short sentence shown in the email client preview pane…',
year: new Date().getFullYear(),
};
const template = hackernoonEmailTemplate(data);
const html = hackernoonRenderToString(template, data);
// write `html` to a file or send via your ESPOr use the dedicated render script to write generated/hackernoon-email.html:
npm run render:hackernoon| Field | Type | Description |
|---|---|---|
title |
string |
Main article headline rendered as <h1> |
preheaderText |
string |
Hidden preview text shown in email client inbox lists |
year |
number |
Copyright year in the footer |
All other content (sponsor card, article body, social links) is static and preserved verbatim from the original hackernoon.html reference file.
The body template is composed from four section files inside src/templates/hackernoon/sections/:
| Section file | Email zone | Dynamic fields |
|---|---|---|
logo.section.ts |
Top logo banner (templatePreheader) |
— |
header.section.ts |
Sponsor card + article content (templateHeader) |
data.title |
body.section.ts |
Closing divider + bottom logo (templateBody) |
— |
footer.section.ts |
Social icons + copyright (templateFooter) |
data.year |
Constants for shared URLs (brand logo, meme GIFs, Mailchimp social icons, Bridgecrew sponsor) live in src/templates/hackernoon/constants.ts.
Converted from email-template-mailchimp (1).html. The template is structured as a modular folder (src/templates/mailchimp/) where each email zone is a separate section file. The top-level mailchimp-email.ts is a backward-compatible re-export shim.
import '@lit-labs/ssr/lib/install-global-dom-shim.js'; // must come first
import { mailchimpEmailTemplate } from './templates/mailchimp-email.js';
import { mailchimpRenderToString } from './renderer.js';
import type { MailchimpEmailData } from './types.js';
const data: MailchimpEmailData = {
title: 'Summer Sale – Up to 40% Off',
preheaderText: 'Shop our best deals before they're gone',
viewInBrowserUrl: 'https://example.com/archive/issue-42',
brandName: 'Brandname',
navLinks: [
{ label: 'Shop', url: 'https://example.com/shop' },
{ label: 'Sale', url: 'https://example.com/sale' },
],
heroImageUrl: 'https://example.com/images/banner.jpg',
heroImageAlt: 'Summer Sale banner',
contentHeading: 'Big Summer Sale',
contentBody: 'Up to 40% off selected lines – this weekend only.',
productRows: [
[
{ imageUrl: '…', imageAlt: '…', meta: 'In Stock', title: 'Widget A',
description: 'A great widget.', previousPrice: '$99', price: '$59', buyUrl: '#' },
{ imageUrl: '…', imageAlt: '…', meta: 'In Stock', title: 'Widget B',
description: 'Another great widget.', previousPrice: '$79', price: '$49', buyUrl: '#' },
],
],
footerColumns: [
{ title: 'About', description: 'We make great widgets.' },
{ title: 'Support', description: 'Help is always available.' },
{ title: 'Legal', description: 'Terms and privacy policy.' },
],
companyName: 'Widgets Inc.',
companyAddress: '1 Widget Lane, Widgetville',
unsubscribeUrl: 'https://example.com/unsubscribe',
updateProfileUrl: 'https://example.com/profile',
};
const template = mailchimpEmailTemplate(data);
const html = mailchimpRenderToString(template, data);
// write `html` to a file or send via your ESPOr use the dedicated render script to write generated/mailchimp-email.html:
npm run render:mailchimp| Field | Type | Description |
|---|---|---|
title |
string |
Email subject / <title> tag |
preheaderText |
string |
Hidden preview text in inbox lists |
viewInBrowserUrl |
string |
"View in browser" link |
brandName |
string |
Brand name in the header bar |
navLinks |
Array<{ label, url }> |
Navigation links in the header bar |
heroImageUrl |
string |
Full-width hero image URL |
heroImageAlt |
string |
Alt text for the hero image |
contentHeading |
string |
Main H1 heading |
contentBody |
string |
Body paragraph below the heading |
productRows |
Array<[ProductItem, ProductItem]> |
Rows of two product cards each |
footerColumns |
[FooterColumn, FooterColumn, FooterColumn] |
Three dark footer columns |
companyName |
string |
Company name in the disclaimer |
companyAddress |
string |
Physical address in the disclaimer |
unsubscribeUrl |
string |
Unsubscribe link |
updateProfileUrl |
string |
Update-profile link |
Each ProductItem carries: imageUrl, imageAlt, meta, title, description, previousPrice, price, buyUrl.
Each FooterColumn carries: title, description.
The body template is composed from seven section files inside src/templates/mailchimp/sections/:
| Section file | Email zone | Dynamic fields |
|---|---|---|
preheader.section.ts |
Hidden preview text row | preheaderText, viewInBrowserUrl |
branding.section.ts |
Dark header bar | brandName, navLinks |
image.section.ts |
Full-width hero image | heroImageUrl, heroImageAlt |
text.section.ts |
H1 + paragraph | contentHeading, contentBody |
product-row.section.ts |
2-column product rows | productRows (via product-row/product-card.ts) |
footer.section.ts |
Three-column dark footer | footerColumns |
disclaimer.section.ts |
Company info + unsubscribe | companyName, companyAddress, unsubscribeUrl, updateProfileUrl |
Lit's html tag is a JavaScript tagged template literal. When the JavaScript engine evaluates a tagged template, it calls the tag function with two arguments:
- an array of the static string parts (the bits between your
${…}expressions) - the dynamic values you interpolated
// Under the hood, the JS engine calls:
// html(["<h2>Hello, ", "! 👋</h2>"], data.recipientName)
html`<h2>Hello, ${data.recipientName}! 👋</h2>`Lit records both the static strings and the dynamic values in a lightweight TemplateResult object. Crucially, no DOM nodes are created yet — the TemplateResult is just a description of what to render. The actual HTML string is only produced later, when you pass it to the SSR renderer.
Because html returns a plain value (TemplateResult), you can interpolate one template inside another. That is how the newsletter's article list is built: a small articleTemplate function returns a TemplateResult for a single row, and the parent template maps over the articles array:
// src/templates/newsletter.ts
function articleTemplate(article: ArticleItem): TemplateResult {
return html`
<div class="article-item">
<img src="${article.imageUrl}" alt="${article.imageAlt}" />
<div>
<h4><a href="${article.url}">${article.title}</a></h4>
<p>${article.excerpt}</p>
</div>
</div>
`;
}
export function newsletterEmailTemplate(data: EmailData): TemplateResult {
return html`
<div class="email-wrapper">
<!-- … other sections … -->
<div class="articles">
<h3>Top Stories This Month</h3>
${data.articles.map(articleTemplate)} <!-- array of TemplateResults -->
</div>
</div>
`;
}The SSR renderer knows how to walk a nested tree of TemplateResult objects and flatten everything into a single HTML string.
Lit was designed for browsers. It references browser globals like Element, HTMLElement, document, and window. When you run Lit in Node.js those globals do not exist. @lit-labs/ssr ships a minimal shim that defines just enough of the DOM API to let the SSR renderer work:
// src/index.ts — must be imported before any lit or lit-ssr code
import '@lit-labs/ssr/lib/install-global-dom-shim.js';The shim only stubs out the subset of the DOM that Lit itself touches internally. It is not a full DOM implementation — don't try to manipulate real DOM nodes with it. Its only job is to stop Lit from crashing when it looks for window.customElements or HTMLElement.
Once the template tree is assembled, it flows through three stages inside renderToString() in src/renderer.ts:
import { render } from '@lit-labs/ssr';
const renderResult = render(template);
// renderResult is a RenderResult — a synchronous Iterable<string | Promise<RenderResult>>render() does not immediately produce a string. It returns a lazy iterator that yields string chunks (and, for async templates, Promises). This design supports streaming: a web server can pipe chunks to the response as they are produced without waiting for the entire template to be ready.
import { collectResultSync } from '@lit-labs/ssr/lib/render-result.js';
const rawBodyContent = collectResultSync(renderResult);collectResultSync walks the iterator, calls any thunks it encounters, concatenates all the string chunks, and returns a single string. It throws if any chunk is a Promise — for that case you would use the async collectResult instead (see Async templates below).
The raw string coming out of stage 2 contains HTML comments that Lit injects to mark dynamic sections for client-side hydration:
<!-- what collectResultSync actually produces -->
<h2>Hello, <!--lit-part-->John Doe<!--/lit-part-->! 👋</h2>
<img <!--lit-node 4-->src="https://…" alt="MyBrand logo" />These markers are essential when Lit hydrates a server-rendered page in a browser, but they are meaningless in email — every email client would render them as visible text or garbled markup. So we strip them with a simple regex pass:
// src/renderer.ts
function stripLitMarkers(html: string): string {
return html
.replace(/<!--lit-part[^>]*-->/g, '')
.replace(/<!--\/lit-part-->/g, '')
.replace(/<!--lit-node[^>]*-->/g, '');
}After stripping, the snippet above becomes the expected clean markup:
<h2>Hello, John Doe! 👋</h2>
<img src="https://…" alt="MyBrand logo" />HTML defines a set of raw-text elements — <script> and <style> — plus escapable raw-text elements like <title> and <textarea>. Parsers treat the inline content of these elements as raw character data rather than normal child nodes (child elements are not parsed inside them).
Lit SSR parses the template's static HTML string with an HTML5 parser to locate expression markers. When it encounters a raw-text element, the parser switches into a mode where it does not look for child nodes — so Lit's expression markers inside <title> are invisible to it. The marker count tracked internally ends up mismatched against the number of values in the template, and the renderer throws:
Error: Unexpected final partIndex: 0 !== 1 while processing the following template:
<html>
<head>
<title>${...} – Monthly Newsletter</title> ← expression never found
…
The fix is to keep the Lit template responsible only for the <body> content (where all elements are normal flow content), and build the <head> — including <title> and <style> — as a plain JavaScript template string in renderToString():
// src/renderer.ts
export function renderToString(
template: TemplateResult,
data: Pick<EmailData, 'brandName'>
): string {
const rawBodyContent = collectResultSync(render(template));
const bodyContent = stripLitMarkers(rawBodyContent);
// <title> interpolation happens here in a plain JS template string,
// completely bypassing Lit's HTML parser.
return `<!DOCTYPE html>
<html lang="en">
<head>
<title>${data.brandName} – Monthly Newsletter</title>
<style>${EMAIL_STYLES}</style>
</head>
<body>
${bodyContent}
</body>
</html>`;
}Here is the full journey from input data to rendered .html file:
EmailData (src/index.ts)
│
│ newsletterEmailTemplate(data)
▼
TemplateResult ← Lit html`` tag; no DOM yet
│
│ render(template) [from @lit-labs/ssr]
▼
RenderResult ← lazy iterable of string chunks + thunks
│
│ collectResultSync(renderResult)
▼
raw HTML string ← includes <!--lit-part--> markers
│
│ stripLitMarkers(raw)
▼
clean body HTML ← pure HTML, no Lit comments
│
│ wrap in document shell (renderToString)
▼
complete .html file ← DOCTYPE + <head> + <body> → dist/rendered-email.html
All data passed to the template is captured in two TypeScript interfaces (src/types.ts):
interface EmailData {
recipientName: string; // personalisation — "Hello, John Doe!"
brandName: string; // used in header, footer, and <title>
logoUrl: string; // src of the header logo <img>
heroSubtitle: string; // paragraph beneath the greeting
bodyText: string; // main body copy
ctaLabel: string; // button text
ctaUrl: string; // button href
articles: ArticleItem[]; // list of featured stories
year: number; // footer copyright year
unsubscribeUrl: string;
privacyUrl: string;
contactUrl: string;
}
interface ArticleItem {
imageUrl: string; // thumbnail src
imageAlt: string; // thumbnail alt text
title: string; // headline, rendered as a link
excerpt: string; // short description
url: string; // headline href
}TypeScript enforces that every field is provided before the template is called, giving you a compile-time safety net instead of a runtime "undefined is not a string" surprise.
Create a new file in src/templates/ that exports a function returning a TemplateResult, mirroring the pattern in newsletter.ts:
// src/templates/transactional.ts
import { html, TemplateResult } from 'lit';
export interface TransactionalData { … }
export function transactionalTemplate(data: TransactionalData): TemplateResult {
return html`
<div class="email-wrapper">
…
</div>
`;
}Then call it from your entry point, pass the result through renderToString, and write the output file.
If a template contains async directives (e.g., fetching data inside a directive), replace collectResultSync with the async equivalent:
import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
const bodyContent = await collectResult(render(template));Most email clients do not support <style> blocks or <link> stylesheets reliably. The safest approach is to inline all CSS into style="" attributes before sending. You can pipe the output through juice:
import juice from 'juice';
const inlined = juice(renderToString(template, data));
// send `inlined` via your ESPBoth the Hacker Noon and No More To-Go templates are already structured using the modular pattern described below. When you add new templates, follow the same conventions.
src/
templates/
hackernoon/ # one folder per template
index.ts # public entry — exports hackernoonEmailTemplate()
types.ts # re-exports template-specific data model
constants.ts # image base URLs, sponsor URLs, icon paths
sections/
logo.section.ts
header.section.ts
body.section.ts
footer.section.ts
nomoretogo/
index.ts # public entry — exports nomoretogoEmailTemplate()
types.ts
constants.ts
sections/
logo.section.ts
nav.section.ts
intro.section.ts
recipe-grid.section.ts
recipe-grid/
recipe-row.ts
cta.section.ts
prep-info.section.ts
community.section.ts
amazon.section.ts
footer.section.ts
hackernoon-email.ts # re-export shim (backward compat)
nomoretogo-email.ts # re-export shim (backward compat)
newsletter.ts # example of a simple single-file template
- One section = one file — keep each section under ~200 lines to stay readable.
index.tsis a composer only — the main template file just imports and assembles sections in order; no raw HTML markup lives there.- Promote reusable parts to
shared/blocks/— CTA buttons, footers, date headers, and social rows that appear in multiple templates belong inshared/(not yet created, but the right place to add them as the library grows). - Separate content model from rendering — use
types.tsfor data contracts. - No magic constants in section files — image base URLs, spacing values, and brand colours go in
constants.ts. - Test per section and snapshot the full template — unit-test individual section helpers; keep at least one snapshot test of the assembled output to catch regressions when refactoring.