Server-render Lit web components from Drupal. No Node.js. No containers. No HTTP sidecar. Just a single binary that speaks NUL bytes.
Backlit hooks into Drupal's response pipeline and renders every Lit web component with Declarative Shadow DOM. Users see styled, laid-out content on first paint -- before any JavaScript loads. Disable JS entirely and the components still render. That is the way of the Lit.
composer require bennypowers/backlit
drush en backlitThat's it. Composer downloads the right binary for your platform. Drush enables the module. Every page response now gets its web components server-rendered.
If the binary download didn't run automatically:
cd web/modules/contrib/backlit
./scripts/download-binary.shBacklit ships a pre-compiled Go binary that embeds a WASM module. Inside that WASM module: QuickJS running @lit-labs/ssr. On startup, the binary loads your component JS files and evaluates them inside QuickJS, registering your custom elements.
When Drupal finishes rendering a page, Backlit's SsrResponseSubscriber intercepts the response, pipes the HTML through the binary's stdin, and reads Declarative Shadow DOM enhanced HTML from stdout. The binary uses a NUL-delimited read-loop protocol, so the WASM instance and your component definitions stay warm across renders.
First render: ~350ms (WASM cold start -- paid once per PHP-FPM worker)
Every render after: ~0.32ms (just pipe I/O)
The binary auto-detects your platform. Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64. Yes, we support Windows. No, we haven't tested it. Godspeed.
Drop plain JavaScript files into one of these locations (checked in order):
$settings['backlit']['components_dir']insettings.php- Your active theme's
components/directory -- e.g.,themes/custom/my_theme/components/ - Any custom module's
js/directory -- e.g.,modules/custom/my_components/js/
Backlit auto-discovers element names from customElements.define() calls. No configuration beyond placing the files.
Standard LitElement, minus import statements (the WASM engine provides LitElement, html, css, classMap, etc. as globals):
class MyCard extends LitElement {
static properties = {
heading: { type: String },
};
static styles = css`
:host { display: block; border: 1px solid #ccc; border-radius: 8px; }
#header { padding: 16px; font-weight: 600; }
#body { padding: 16px; }
`;
constructor() {
super();
this.heading = '';
}
render() {
return html`
<div id="header">${this.heading}</div>
<div id="body"><slot></slot></div>
`;
}
}
customElements.define('my-card', MyCard);Then use it in any Drupal content (Full HTML format):
<my-card heading="Dashboard">
<p>All systems operational.</p>
</my-card>No build step, no npm, no bundler. Components stay registered across renders -- the WASM engine evaluates your JS once and keeps the definitions warm.
For maximum performance, you can build a custom WASM module with your components baked in, skipping JS evaluation entirely:
- Clone lit-ssr-wasm
- Write your components in
src/components/ - Import them in
src/entry.ts, add tag names toKNOWN_ELEMENTS npm run build(requires Javy)- Build the CLI:
cd go && make linux-x64 - Replace the binary in Backlit's
bin/directory
| Metric | Value |
|---|---|
| Cold start | ~350ms (once per PHP-FPM worker) |
| Warm render | ~0.32ms |
| Binary size | ~9 MB (statically linked, no dependencies) |
| Memory | ~20 MB per WASM instance |
| Dependencies | Zero. The binary is the dependency. |
For comparison, the previous approach required a Node.js container, HTTP round-trips, and ~50ms per render. The drop has moved.
- Drupal 10 or 11
- PHP 8.1+
- A server that can run a binary (so, any server)
- No PHP extensions, no PECL, no FFI, no containers, no npm
If the binary isn't available or fails to start, Backlit returns the original HTML unchanged. Your components will still work client-side once their JavaScript loads -- they just won't have the instant first paint from DSD. This means you can develop locally without the binary and deploy with it.
Browser request
|
Drupal renders page (Twig, etc.)
|
KernelEvents::RESPONSE
|
SsrResponseSubscriber
|
LitSsrRenderer::render()
|
proc_open() -> lit-ssr binary (Go + wazero + QuickJS + @lit-labs/ssr)
|
stdin: HTML\0 -> stdout: HTML-with-DSD\0
|
Response sent to browser with Declarative Shadow DOM
|
User sees styled content immediately
(JavaScript loads later, hydrates if needed)
Does this work with any web component framework?
Only Lit. The SSR engine is @lit-labs/ssr, which understands Lit's template system. Vanilla custom elements or other frameworks would need their own SSR implementation.
What about caching? Backlit operates on every response. If you have Drupal's page cache enabled, the rendered HTML (with DSD) gets cached, so subsequent requests skip the binary entirely. This is the recommended setup.
Can I use this in production? The binary is statically linked with no runtime dependencies. The protocol is simple (NUL-delimited pipes). The failure mode is graceful (returns original HTML). So... probably? But this is still early days. File issues, send PRs, report back.
Why "Backlit"? Because your Lit components are lit from behind -- server-rendered before the browser even sees them. Also because naming things is hard and this one was available.
- lit-ssr-wasm -- the WASM module, Go library, and browser demo
- Live demo -- runs the WASM module in your browser
- Blog post -- the full story
- Previous approach (2024) -- the Node.js sidecar version
MIT
