Widgets
Widgets are self-contained interactive components embedded in page content. They extend WidgetComponent instead of PageComponent and follow the same lifecycle: getData() → renderHTML() / renderMarkdown().
Create a widget
Place widgets in widgets/{name}/{name}.widget.ts:
import { WidgetComponent } from '@emkodev/emroute';
interface CounterData {
count: number;
}
class CounterWidget extends WidgetComponent<{ start?: string }, CounterData> {
override readonly name = 'counter';
override getData({ params }: this['DataArgs']) {
return Promise.resolve({ count: parseInt(params.start ?? '0', 10) });
}
override renderHTML({ data }: this['RenderArgs']) {
if (!data) return '';
return `<button class="dec">-</button>
<span class="count">${data.count}</span>
<button class="inc">+</button>`;
}
override renderMarkdown({ data }: this['RenderArgs']) {
return data ? `Counter: ${data.count}` : '';
}
}
export default new CounterWidget();
Like page components, the file must export default an instance.
Enable widget discovery
The default widgetsDir is /widgets. The runtime scans the directory and registers all widgets automatically. To use a different directory, pass it to your runtime config:
const runtime = new BunFsRuntime(appRoot, {
widgetsDir: '/components',
});
Embed widgets in pages
In HTML (.page.html or renderHTML())
Use the custom element tag <widget-{name}>. Attributes become parameters:
<widget-counter start="42"></widget-counter>
HTML normalizes attribute names to lowercase. Use lowercase names like
courseid(notcourseId). Kebab-case attributes are converted to camelCase inparams:my-count→myCount.
In Markdown (.page.md or renderMarkdown())
Use fenced block syntax. JSON keys become parameters:
```widget:counter
{"start": "42"}
```
Omit the JSON body when the widget takes no parameters:
```widget:nav
```
Don't paste raw
<widget-{name}>tags into.page.mdfiles. Markdown renderers escape inline HTML by default, so<widget-counter></widget-counter>in a.mdbecomes<widget-counter></widget-counter>in the HTML output — your widget will not appear. Use the fenced block syntax above in markdown; the raw tag syntax is for.page.htmlfiles andrenderHTML()strings only.
SSR output
In SSR HTML mode, widgets render server-side with Declarative Shadow DOM:
<widget-counter start="42" ssr>
<template shadowrootmode="open">
<button class="dec">-</button>
<span class="count">42</span>
<button class="inc">+</button>
</template>
</widget-counter>
In SSR Markdown mode, the fenced block is replaced with the widget's renderMarkdown() output:
Counter: 42
Companion files
Widgets support the same companion files as pages:
widgets/counter/
counter.widget.ts ← Widget module
counter.widget.html ← HTML template (optional)
counter.widget.css ← Scoped styles (optional)
counter.widget.md ← Markdown template (optional)
CSS companions are wrapped in @layer emroute { ... } and applied inside the widget's shadow DOM. Shadow DOM isolates the styles; the @layer ensures companion CSS has lower cascade priority than inline <style> tags in renderHTML(). Write plain CSS — wrapping happens automatically.
Default :host styles
Every widget receives a base stylesheet (via @layer emroute-base, lower priority than companion CSS):
:host { display: block; }
:host([hidden]) { display: none; }
display: block— custom elements areinlineby default, which breaks width/height. Block is the right default for widgets.hiddensafeguard — ensures thehiddenattribute works even though:hostsets an explicit display.
To override, write :host { ... } in your companion CSS — it lives in @layer emroute which takes priority over @layer emroute-base.
Opt-in performance and container queries
These properties are useful but have trade-offs, so they are not set by default. Add them in your companion CSS when needed:
/* Container queries — widget responds to its own width, not the viewport.
Implies contain: inline-size — the host element won't derive its width
from its children. Ensure the parent layout gives the widget explicit
or flex/grid sizing. */
:host { container-type: inline-size; }
/* Skip layout/paint for off-screen widgets. Set contain-intrinsic-size
to avoid scroll height jumps. */
:host { content-visibility: auto; contain-intrinsic-size: auto 200px; }
To override any of these, write :host { ... } in your companion CSS — it lives in @layer emroute which takes priority over @layer emroute-base.
Hydration (SPA mode)
When using SPA mode, widgets can add interactivity after rendering via the hydrate() lifecycle hook:
override hydrate({ data }: this['RenderArgs']) {
const button = this.element?.shadowRoot?.querySelector('#btn');
button?.addEventListener('click', this.handleClick);
}
override destroy() {
const button = this.element?.shadowRoot?.querySelector('#btn');
button?.removeEventListener('click', this.handleClick);
}
hydrate() is called after both SSR adoption and fresh SPA rendering. Use this.element to access the host <widget-{name}> custom element (only available in the browser — undefined on the server).
data in hydrate() after SSR adoption
When a widget is SSR'd and the client adopts the server-rendered DOM, the client does not re-run getData() — that would defeat the purpose of SSR. By default this means hydrate({ data }) receives data: null in that flow. You have two options.
Option 1: read state from the DOM. The SSR output already contains everything the user can see — query the shadow root for the values you need:
override hydrate() {
const span = this.element?.shadowRoot?.querySelector('.count');
let count = Number(span?.textContent ?? 0);
// ...
}
Option 2: opt into exposeSsrData. Set override readonly exposeSsrData = true on the widget class. The server serializes the getData() result as JSON text in light DOM; the client parses it back into this.data before hydrate() runs, so data is populated:
class CounterWidget extends WidgetComponent<{ start?: string }, CounterData> {
override readonly name = 'counter';
override readonly exposeSsrData = true;
// ...
override hydrate({ data }: this['RenderArgs']) {
let count = data?.count ?? 0; // data is populated here
}
}
On client-side SPA navigation (no SSR adoption), getData() runs as usual and hydrate() receives the freshly fetched data regardless of exposeSsrData.
Anchors & highlighting
Give any widget an anchor and it becomes a scroll target. Emroute renders anchor="foo" as id="anchor-foo" on the host element — so navigating to #anchor-foo scrolls it into view and :target styles it. This is native browser behavior: no JavaScript, and it works in every SPA mode, including none.
In HTML (.page.html or renderHTML())
<widget-course anchor="course-42"></widget-course>
In Markdown (.page.md or renderMarkdown())
Markdown can't carry raw HTML attributes, but widget params can — anchor is the markdown-reachable spelling of id:
```widget:course
{"anchor": "course-42"}
```
Both render the host as <widget-course id="anchor-course-42">.
Per-entity anchors
When rendering a widget per entity in a loop, append the entity's id so each host is uniquely addressable:
override renderHTML({ data }: this['RenderArgs']) {
return data.courses
.map((c) => `<widget-course-card anchor="course-${escapeHtml(c.id)}"></widget-course-card>`)
.join('');
}
Style the highlight
Emroute ships no highlight visual — you decide what "highlighted" looks like, using the native :target pseudo-class in your page CSS. A single attribute selector covers every anchored element:
[id^="anchor-"]:target {
outline: 2px solid var(--accent);
outline-offset: 4px;
scroll-margin-block: 2rem; /* keep it clear of a sticky header */
}
Use a page stylesheet (
.page.css), not a widget companion.:targetmatches the host element, which lives in light DOM — shadow-DOM companion CSS can't reach it.scroll-margin-blockkeeps the scrolled-to widget from tucking under a sticky header.
The highlight stays until the next fragment navigation — deliberately, so it remains visible while the reader looks at it. Because the anchor lives in the URL, it survives reload and is shareable.
For a smooth animated scroll instead of an instant jump, set scroll-behavior: smooth on your scroll container (usually :root) in global CSS — guard it with @media (prefers-reduced-motion: no-preference) so it respects users who prefer reduced motion. It's native CSS; no JavaScript.
See it in action on the Anchor & Highlight page.
Best practices
Don't override global HTML attributes
When setting attributes like role or tabindex in hydrate(), check whether the consumer has already set them. Overriding author-set globals breaks accessibility and developer intent:
override hydrate() {
const el = this.element!;
// Respect consumer-set values — only apply defaults
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
}
Never unconditionally write to role, tabindex, aria-*, class, or other global attributes — the consumer may have set them deliberately.
Working examples
The Widget Gallery publishes this guide's own widgets: a live instance of each, its full source, and the compiled single-file .js you can drop straight into your widgets/ directory.
Next: Server Setup