html★ is a client-side library — it doesn't render HTML on the server. But html★ backends that use web components need a way to expand <product-card> into real HTML server-side. A component registry solves this.
The Problem
You write your UI with custom elements:
<product-card slug="running-shoe"></product-card>
On the client, the Custom Element definition renders this into real HTML. But on the server — for initial page loads, for html★ fragments, for no-JS users — the tag is empty. You need server-side rendering.
The Registry Pattern
A component registry maps tag names to render functions:
" `="" <product-card>="" <h3>${attrs.title}<="" h3>="" <p>${attrs.price}<="" p>="" ${children}="" <="" product-card>="" `,="" 'user-avatar':="" (attrs)=">" <user-avatar>="" <img="" src="/avatars/${attrs.user}.jpg" alt="${attrs.name}" width="40" height="40" >="" user-avatar>="" 'price-tag':="" <price-tag>="" <data="" value="${attrs.amount}" >${parseFloat(attrs.amount).toFixed(2)}<="" data>="" price-tag>="" }<="" code-block=""><h2>Fragment Renderer</h2><p>Combine the registry with a fragment renderer:</p><code-block language="javascript" server="" render.js="" import="" {="" registry="" }="" from="" '.="" components.js'="" export="" function="" renderComponent(tagName,="" attrs="{}," children="" )="" const="" renderer="registry[tagName]" if="" (!renderer)="" return="" `<${tagName}>${children}<="" ${tagName}>`="" renderer(attrs,="" children)="" renderFragment(template,="" data)="" Replace="" component="" placeholders="" with="" rendered="" HTML="" template.replace(="" <(\w+-\w+)([^>]*)>([\s\S]*?)<\="" \1>="" g,="" (match,="" tag,="" attrStr,="" renderComponent(tag,="" ...attrs,="" ...data="" },="" parseAttrs(str)="" str.replace(="" (\w+)="([^" ]*)"="" (_,="" key,="" val)=">" attrs[key]="val" })="" }<="" code-block=""><h2>Using with html★</h2><p>Route handlers use the registry for both full pages and fragments:</p><code-block language="javascript">app.get('/products/:slug', async (req, res) => { const product = await db.getProduct(req.params.slug) const cardHtml = renderComponent('product-card', { title: product.name, price: product.price }) if (req.headers['x-requested-with'] === 'htmlstar') { res.set('Vary', 'X-Requested-With') return res.send(cardHtml) } res.send(renderPage('product', { content: cardHtml }))})
Implementation Options
You don't need to build a registry from scratch. Several libraries handle server-side custom element rendering:
Option 1: enhance-ssr
The @enhance/ssr package is a standalone SSR engine for web components. No framework lock-in — it's a single npm package:
import enhance from '@enhance/ssr' const html = enhance({ elements: { 'product-card': ({ html, state }) => html` <h3>${state.attrs.title}</h3> <p>${state.attrs.price}</p> ` }, initialState: { product }, bodyContent: true // fragment mode — no document wrapper}) const fragment = html`<product-card title="${product.name}" price="${product.price}"></product-card>`
Option 2: Lit SSR
If you use Lit for your client-side components, @lit-labs/ssr can render them server-side:
import { render } from '@lit-labs/ssr'import { html } from 'lit'import './components/product-card.js' const result = render(html`<product-card .product=${product}></product-card>`)
Option 3: Minimal Custom (Recommended for small projects)
For small projects, a 50-line registry is all you need. The render functions are just template literals — no library required.
Best Practices
Keep render functions pure. They take data in, return HTML out. No side effects, no database calls.
Match client and server output. The server-rendered HTML should match what the client-side Custom Element produces. This prevents content flash when components upgrade.
Use light DOM. Render into the custom element's light DOM (not shadow DOM) so the HTML is visible without JavaScript and html★ can swap it.
Progressive enhancement. Server renders the readable HTML. Client-side Custom Element adds interactivity (event handlers, animations, etc.) without replacing the content.
${this.getAttribute('title')}</h3>`" destroys="" server="" HTML="" }<="" code-block="">