Node (Express / Connect / any (req, res, next) server)
The widest-reach adapter: a Connect-style middleware for plain Node servers that have no meta-framework of their own. If your server speaks the (req, res, next) contract — Express, Connect, raw http (with a middleware shim), Fastify, Koa — this is the integration.
It buffers each response and, for text/html only, runs the body through renderToString once before it's flushed, fixing Content-Length. Everything else (JSON, assets, redirects) passes through untouched, as does any response whose headers were already sent or whose transform throws — the page still works, it just isn't pre-rendered.
// server.js
import "@webtides/element-js-ssr-renderer/dom-shim"; // must come first: installs HTMLElement etc.
import express from "express";
import { elementSSR } from "@webtides/element-js-ssr-renderer/node";
import catalog from "@webtides/element-library/catalog";
const app = express();
// Mount it BEFORE your routes so it can wrap their output.
app.use(
elementSSR({
resolve: [
catalog, // a third-party library's shipped catalog
// your own components — a hand-written lazy Catalog (no `import.meta.glob` here):
{ "x-counter": () => import("./src/components/x-counter.js") },
],
}),
);
app.get("/", (_req, res) =>
res.type("html").send("<x-counter count='3'></x-counter>"),
);
app.listen(3000);elementSSR takes the same sources as every other adapter (see Resolving components). There's no Vite import.meta.glob in a plain Node server, so resolve your own components with a hand-written { tag: () => import(…) } Catalog, or generate one with the CLI:
element-js-ssr-renderer catalog ./src/components -o ./src/catalog.jsMount order matters
The middleware overrides res.write/res.end to buffer the body. Register it before the routes (or other body-writing middleware) whose output you want to transform. If a route flushes headers itself (a raw res.writeHead(...) before any body), that response is passed through unchanged.
Other servers — same middleware, different mount
The adapter is a standard (req, res, next) function, so it drops into anything that accepts Connect-style middleware. Only the mounting line differs:
Connect
import connect from "connect";
const app = connect();
app.use(elementSSR({ resolve: [catalog] }));Raw node:http
No native middleware concept — wrap it with connect (or call the returned function yourself with a next that runs your handler):
import http from "node:http";
const ssr = elementSSR({ resolve: [catalog] });
http
.createServer((req, res) => {
ssr(req, res, () => {
res.setHeader("content-type", "text/html");
res.end("<x-counter></x-counter>");
});
})
.listen(3000);Fastify
Fastify isn't Connect-native; add @fastify/middie (or @fastify/express) to register (req, res, next) middleware:
import Fastify from "fastify";
import middie from "@fastify/middie";
const app = Fastify();
await app.register(middie);
app.use(elementSSR({ resolve: [catalog] }));Koa
Koa's middleware signature is (ctx, next), not (req, res, next). Adapt with koa-connect:
import Koa from "koa";
import c2k from "koa-connect";
const app = new Koa();
app.use(c2k(elementSSR({ resolve: [catalog] })));Hono (Node)
On the Node runtime, use Hono's Node server and bridge the request through the same middleware via koa-connect-style adapters, or transform the HTML directly with renderToString in a Hono middleware — for an edge/Workers deployment the runtime-FS caveats apply, so resolve with a static catalog.
Streaming
This adapter buffers the whole response, then transforms once. That's correct for the ordinary "render the page, send it" servers it targets. If you stream a response incrementally (res.write chunk-by-chunk to the client as you go), buffering defeats the streaming — that's the streaming-first concern tracked separately for the streaming meta-frameworks, not something this plain-Node adapter solves.
Client-side hydration
A plain Node server has no bundler, and browsers can't resolve bare specifiers like @webtides/element-js. So a naïve <script type="module">import "@webtides/element-library/button/define"</script> won't load in the browser. Two ways to ship the client defines:
- Bundle a client entry (esbuild/Rollup/Vite) that imports each component's
define(or calls your localdefine()), and serve the output as a static file — the most robust option. - Import map — serve
node_modulesstatically and add a<script type="importmap">mapping the bare specifiers to those paths. No build step, but fiddly with packages that have many internal imports.
Either way, the SSR output already carries element-js' <!--template-part--> markers, so once the defines run the elements hydrate in place rather than re-rendering.
Runnable example
A complete, runnable Express version lives in examples/node/ — it composes element-library's shipped catalog with its own components (a hand-written lazy Catalog), covering both the shadow (DSD) and light-DOM paths. It's deliberately SSR-only to isolate the adapter; see above for adding hydration.
cd examples/node && npm install && npm start
# → http://localhost:3000 (curl it or view source to see the pre-rendered DSD)