Lit SSR in pure Go. Server-side render Lit web components into Declarative Shadow DOM HTML without Node.js.
golit runs your Lit component code in an embedded JavaScript engine (QuickJS via WebAssembly) and produces Declarative Shadow DOM HTML. classMap, directives, private fields, reactive state all work because the real component render() method executes. Built in Go with zero CGo dependencies.
Lit web components render empty shells until JavaScript loads. The official @lit-labs/ssr requires Node.js, which adds complexity and slows Go-based SSG builds. golit puts full-fidelity Lit SSR in a single Go binary.
Before golit: Users see a blank component until JS downloads, parses, and executes.
After golit: Users see rendered content on first paint. Lit hydrates for interactivity in the background.
- Go 1.25+
- Component source files available locally on disk (installed via npm)
golit bundles and executes component source code at build time using esbuild. The component library and its dependencies must be installed locally, via npm install in your project.
npm install @rhds/elements # or whatever component library you useCDN import maps are not supported for SSR. golit cannot fetch and bundle from remote URLs. If your HTML uses a CDN-based import map (e.g. https://ga.jspm.io/...), you must either:
- Install the packages locally and use an import map with local paths for golit
- Use
--sourcesto point at the localnode_modulesdirectory - Use
--importmapwith a build-time import map that resolves to local files
The browser still uses your CDN import map at runtime for hydration. golit only needs local files during the build.
Homebrew (macOS and Linux):
brew tap zeroedin/golit
brew install golitGo install:
go install github.com/zeroedin/golit/cmd/golit@latestBinary downloads: grab a pre-built binary from the releases page.
Write your HTML with custom elements and an import map that points to local files:
<!DOCTYPE html>
<html>
<head>
<script type="importmap">{
"imports": {
"@rhds/elements/": "./node_modules/@rhds/elements/elements/"
}
}</script>
<script type="module">
import '@rhds/elements/rh-badge/rh-badge.js';
</script>
</head>
<body>
<rh-badge state="success" number="7">7</rh-badge>
</body>
</html>Then run golit:
golit transform public/golit reads the import map and module imports from your HTML, resolves them to local files, bundles and executes the components, and injects Declarative Shadow DOM. No configuration files, no separate bundle step.
Note: The import map paths must resolve to files on disk. If your production HTML uses CDN URLs in the import map, provide a separate build-time import map via
--importmapthat points to localnode_modules.
golit supports four ways to discover which components to SSR. All modes are combinable.
golit reads <script type="importmap"> and <script type="module"> directly from the HTML being transformed. Components are bundled on-demand.
golit transform public/Pass an import map file explicitly. golit reads module imports from the HTML and resolves them through your import map.
golit transform public/ --importmap importmap.jsonPoint at a directory of component source files. golit bundles each one and makes them available for rendering.
golit transform public/ --sources node_modules/@rhds/elements/elements/For CI/CD or when you want maximum transform speed, pre-bundle components and point at the output directory. Bundling a directory discovers shared dependencies, produces a shared runtime module, and thin per-component ES modules.
golit bundle node_modules/@rhds/elements/elements/ --out bundles/
golit transform public/ --defs bundles/golit needs local files on disk to bundle components for SSR. If your HTML import map uses CDN URLs (common for production browser delivery), golit cannot resolve them to local source files.
Two approaches:
-
Pre-bundle (recommended for CI/CD): Use
golit bundle <src-dir/> --out <dir/>to pre-bundle fromnode_modules/directly. Then use--defs <dir/>at transform time. The import map in your HTML is irrelevant to golit. esbuild resolves everything fromnode_modules/. -
Local import map (for auto-discovery): Keep the CDN import map in your HTML for the browser, but provide a separate local import map via
--importmap(orimportmap:in golit.yaml) that maps the same specifiers tonode_modules/paths:{ "imports": { "@rhds/elements/": "./node_modules/@rhds/elements/elements/", "@lit-labs/ssr-client/": "./node_modules/@lit-labs/ssr-client/" } }golit transform public/ --importmap importmap.local.json
golit reads the
<script type="module">imports from your HTML, resolves them through the local import map (instead of the CDN one in the HTML), and bundles from local files.
All flags are optional and combinable:
golit transform public/ --defs bundles/ --importmap importmap.json --sources extra/ --out dist/No special Hugo module or shortcode is needed. Write your HTML templates with custom elements as normal, build with Hugo, then post-process:
hugo build && golit transform public/If your import map uses paths relative to the site root, golit resolves them. For more control, pass an import map via CLI:
hugo build && golit transform public/ --importmap importmap.jsonA complete working example using Red Hat Design System components is included in examples/hugo-rhds/. Two build modes are available:
cd examples/hugo-rhds
npm install
# Pre-bundled (shared runtime + thin modules, then transform)
make serve
# Auto-discovery (no pre-bundle step, discovers from HTML import maps)
make serve-autoBoth produce server-side rendered web components at http://localhost:8080. The auto-discovery path uses a local import map (importmap.local.json) to resolve CDN specifiers to node_modules/ for SSR bundling.
For authoring content without SSR (faster iteration with Hugo's live-reload dev server):
make serve-devSee examples/hugo-rhds/Makefile for the full build pipeline.
These examples show how to SSR Lit in a dynamic app (front controller or Rack) instead of batch-transforming static files.
| Example | Directory | Default URL |
|---|---|---|
| PHP (built-in server) | examples/php-middleware/ |
http://localhost:8080 |
| Ruby (Rack) | examples/ruby-middleware/ |
http://localhost:9292 |
Each demo includes a small Lit component (<my-counter>), golit bundle output under bundles/, and middleware that sends HTML through golit before the response is returned.
By default, containers and make serve start a long-lived golit serve process that keeps a warm engine pool. The app posts each full HTML document to POST /render (see environment variable GOLIT_SERVE_URL). That avoids spawning golit transform on every request.
HTTP mode (default):
GET /health-- readiness probePOST /render-- body is full HTML; response is transformed HTML
Stdio mode (--stdio): NUL-delimited stdin/stdout protocol for same-machine integration without HTTP overhead.
CLI usage (same binary as transform / bundle):
golit serve --defs bundles/ --listen 127.0.0.1:9777
golit serve --defs bundles/ --stdioFlags: --defs (or GOLIT_DEFS), --listen (or GOLIT_SERVE_LISTEN; default 127.0.0.1:9777), --stdio, --sources, repeatable --ignore.
If GOLIT_SERVE_URL is unset, the examples fall back to running golit transform on a temporary directory for each HTML response (simpler deployment, higher latency).
| Variable | Purpose |
|---|---|
GOLIT_SERVE_URL |
Base URL of golit serve (e.g. http://127.0.0.1:9777). When set, middleware uses HTTP instead of exec. |
GOLIT_DEFS |
Directory of .golit.module.js files and _runtime.golit.module.js (used by golit serve and cold-path transform). |
GOLIT_BIN |
Path to golit binary for the cold path only. |
GOLIT_DISABLED |
If set (e.g. 1), skip SSR and serve untransformed HTML (used by benchmarks for A/B comparison). |
QuickJS SSR (used by golit transform and golit serve rendering) exposes globalThis.fetch backed by Go net/http (similar in spirit to @lit/ssr + node-fetch). matchMedia is not defined server-side (viewport-only).
| Variable | Purpose |
|---|---|
GOLIT_SSR_LOCATION |
Base URL string for globalThis.location (default http://localhost/), aligned with Lit’s getWindow() default. |
GOLIT_FETCH_ALLOWLIST |
Optional comma-separated hostnames (no scheme/port). If set, fetch may only request those hosts (mitigates SSRF). If unset, only http: / https: are allowed. |
GOLIT_FETCH_TIMEOUT_SEC |
Per-request timeout in seconds (default 10, clamped). |
GOLIT_FETCH_MAX_BODY_BYTES |
Max response body bytes read (default 16 MiB, capped). |
After upgrading golit, regenerate pre-bundled modules (e.g. make bundle in examples/hugo-rhds) so the shared runtime and domshim match the new binary.
Prerequisites: Go (to build golit from this repo), PHP 8+, Node/npm for npm install during make build.
cd examples/php-middleware
npm install
make serve # http://localhost:8080 — starts golit serve + PHPContainer (build from repository root; uses Podman in the Makefile — use docker build / docker run if you prefer):
cd examples/php-middleware
make container # image: golit-php
make container-run # publishes port 8080Without SSR (static public/ only, for comparison): make serve-raw.
Prerequisites: Go, Ruby 3+, Bundler, Node/npm.
cd examples/ruby-middleware
npm install
bundle install
make serve # http://localhost:9292 — golit serve + rackupContainer:
cd examples/ruby-middleware
make container # image: golit-ruby
make container-run # publishes port 9292From each example directory, scripts compare SSR on vs off using curl timings (TTFB, total time, response size), startup time from container run until a static asset returns 200 (so HTML routes stay cold), cold first HTML request per endpoint right after that probe, plus container memory from stats on the host after the HTTP load. Optional tiers use Chrome for client metrics and traces (no extra load-test binaries).
Requirements: curl, make, and podman or docker on PATH. bench.sh picks one OCI binary (podman if present, otherwise docker) and uses it for run, rm, stats, and make container CONTAINER_RUNTIME=… (so the image build matches the runtime that runs the bench). python3 is used for millisecond startup timing when available (otherwise startup is second-rounded). For make bench-full / bench-trace: Google Chrome at the default macOS path (headless). For make container / container-run when not using bench.sh, the default is podman; pass CONTAINER_RUNTIME=docker to use Docker.
cd examples/php-middleware # or ruby-middleware
make bench # 100 requests per endpoint (/ and /about), tier 1 only
make bench-quick # 20 requests
make bench-full # adds --browser and --trace (Chrome)
make bench-trace # Chrome trace files for chrome://tracingDirect script flags:
./bench.sh -n 50 # custom request count
./bench.sh --browser # Performance API metrics via headless Chrome
./bench.sh --trace # CPU trace JSON for flame chartsResults, raw CSVs, memory snapshots (mem_with.snapshot, mem_without.snapshot), startup files (startup_with_ms.txt, startup_without_ms.txt), and per-endpoint cold-request lines (with_first_*.csv, without_first_*.csv) are written to bench-results/ (gitignored). The script builds the image with make container using the same detected podman/docker as run, then runs with golit (warm golit serve in the entrypoint), then without (GOLIT_DISABLED=1), and prints a side-by-side summary.
Reading the numbers: Startup is instance readiness (from container run until GET /components/my-counter.js returns 200). That window includes container boot and the entrypoint; the with − without delta is mostly starting golit serve, paid once per new container instance, not on every HTTP request. Cold first HTML is the first GET to each benchmarked path after that static probe, so it measures one cold trip through the SSR path without warming / or /about during the health wait. Steady-state timings are the bulk curl runs after that. In production you can prewarm (e.g. readiness checks or startup requests that hit real SSR URLs) to move cold cost into deploy/scale-up instead of the first user.
Post-process HTML files, expanding custom elements into Declarative Shadow DOM.
golit transform <html-dir> [options]Options:
--defs <dir>-- Directory of pre-bundled.golit.module.jsfiles (and_runtime.golit.module.js)--sources <dir>-- Directory of component.jssource files (auto-bundles)--importmap <file>-- Import map JSON file for resolving bare-module specifiers--out <dir>-- Output to a separate directory (default: in-place)--quiet/-q-- Suppress warnings (unregistered elements, render failures)--verbose-- Print progress to stderr--dry-run-- Process without writing-j [N]/--concurrency [N]-- Process files in parallel.-jalone uses all available CPUs;-j 4uses 4 workers. Default is sequential.
When no discovery flags are provided, auto-discovery from HTML is used.
Take component source files from node_modules/ and produce self-contained JavaScript modules that golit can execute for SSR. The bundler resolves all imports (Lit, decorators, CSS, dependencies) and outputs .golit.module.js files ready for --defs.
You can bundle a single component or an entire directory of components:
golit bundle <source.js> [--out <file.golit.module.js>] [options]
golit bundle <src-dir/> [--out <modules-dir/>] [options]Single file bundles everything into one .golit.module.js. Use this when you want to bundle one component at a time, or when bundling a third-party component like Web Awesome's wa-button:
golit bundle node_modules/@awesome.me/webawesome/dist/components/button/button.js \
--out bundles/wa-button.golit.module.jsDirectory mode scans all .js files in the directory, figures out which dependencies they share (Lit, reactive-element, etc.), and splits the output into:
_runtime.golit.module.jscontaining all shared code (loaded once)- One thin
.golit.module.jsper component (imports from the shared runtime)
This avoids duplicating Lit across 30 component bundles. The shared runtime loads once per QJS engine instance.
golit bundle node_modules/@rhds/elements/elements/ --out bundles/
# produces: bundles/_runtime.golit.module.js
# bundles/rh-badge.golit.module.js
# bundles/rh-card.golit.module.js
# ...Then use the output with any golit command:
golit transform public/ --defs bundles/
golit render --defs bundles/ '<rh-badge state="success" number="7">7</rh-badge>'
golit serve --defs bundles/Options:
--minify-- Minify the output--target <val>-- ES target (esnext,es2015-es2024; default:es2022)--format <val>-- Output format (esm,cjs,iife; default:esm)--platform <val>-- Platform (neutral,browser,node; default:neutral)--conditions <val>-- Export conditions, comma-separated (default:browser)--main-fields <val>-- package.json fields, comma-separated (default:module,main)
Render a single HTML fragment to stdout. Useful for testing and scripting.
Requires pre-built modules (see golit bundle above).
# First, bundle the component(s) you want to render
golit bundle node_modules/@rhds/elements/elements/ --out bundles/
# Then render a fragment using the pre-built modules
golit render --defs bundles/ '<rh-badge state="success" number="7">7</rh-badge>'
# Pipe large HTML from stdin (avoids OS ARG_MAX limits)
cat page.html | golit render --defs bundles/With no HTML argument and a piped stdin, golit reads the fragment from stdin. Use this for large HTML payloads that would exceed OS argument length limits.
Options:
--quiet/-q-- Suppress warnings (unregistered elements, render failures)
Run a long-lived server with a warm engine pool. Transforms full HTML documents on each request, avoiding per-request golit transform process startup. Built for middleware integration (PHP, Ruby, etc.).
Two transport modes are available:
golit serve --defs bundles/ [--listen host:port]GET /healthreturns200and plain textok.POST /renderaccepts a full HTML document as the body; response is transformed HTML (text/html).
golit serve --defs bundles/ --stdioUses a NUL-delimited (\0) stdin/stdout protocol instead of HTTP. Write HTML terminated by \0 to stdin, read rendered HTML terminated by \0 from stdout. The process stays alive across requests with the same warm engine pool.
Stdio mode works well when the caller runs on the same machine. Pipe liveness is implicit (broken pipe = process died), so no /health endpoint is needed. --stdio and --listen are mutually exclusive.
Options: --quiet / -q suppresses warnings on both modes.
See Middleware examples for how the PHP/Ruby demos wire this up.
Print the version.
golit can be used as a Go library. Import github.com/zeroedin/golit and use the Renderer type:
package main
import (
"fmt"
"log"
"github.com/zeroedin/golit"
)
func main() {
renderer, err := golit.NewRenderer(golit.RendererOptions{
DefsDir: "bundles/",
})
if err != nil {
log.Fatal(err)
}
defer renderer.Close()
output, err := renderer.RenderFragment(`<my-el name="World"></my-el>`)
if err != nil {
log.Fatal(err)
}
fmt.Println(output)
}The Renderer exposes three rendering methods:
RenderFragment(html)-- Render an HTML fragmentRenderHTML(html)-- Render a full HTML documentTransformDir(dir)-- Process all HTML files in a directory
For lower-level control, use the pkg/jsengine and pkg/transformer packages directly.
HTML with import map + module scripts
|
| golit transform
| 1. Parse HTML, find <script type="importmap"> and <script type="module">
| 2. Resolve bare-module specifiers to file paths via import map
| 3. Discover shared dependencies, build thin ES modules + shared runtime
| 4. Load shared runtime (@golit/runtime) into QJS once
| 5. Load thin component modules (import from shared runtime)
| 6. For each custom element in HTML:
| a. Instantiate component, set attributes, call render()
| b. Collect rendered HTML + CSS
| c. Wrap in <template shadowroot="open" shadowrootmode="open">
v
HTML with Declarative Shadow DOM
|
| Browser
v
Instant paint -> Lit hydrates -> Interactive
golit uses three key technologies:
- esbuild (Go-native) -- Three-pass build: (1) discovers shared dependencies via Metafile analysis, (2) produces thin per-component ES modules with shared deps as external imports, (3) bundles the shared runtime from the discovered dependency graph. Handles imports, decorators, private fields, and module resolution. Uses
"browser"conditional exports so packages resolve browser-compatible entry points (avoidingnode:built-in imports that QuickJS cannot provide). - QJS (QuickJS via WebAssembly/Wazero) -- Loads the shared runtime module once via
JS_SetModuleLoaderFunc, then evaluates thin component modules that import from it. Pure Go, no CGo, cross-compiles everywhere. ~2MB WASM module, ~400ms cold start, <1ms per render. - golang.org/x/net/html -- Parses and transforms HTML documents, inserting Declarative Shadow DOM templates.
golit produces Lit-compatible Declarative Shadow DOM with hydration markers:
<rh-badge state="success" number="7">
<template shadowroot="open" shadowrootmode="open">
<style>/* component CSS */</style>
<!--lit-part hqKOHqwbWgk=-->
<!--lit-node 0--><span class="success">
<!--lit-part-->7<!--/lit-part-->
</span>
<!--lit-node 2--><slot class="success"></slot>
<!--/lit-part-->
</template>
7
</rh-badge><!--lit-part DIGEST-->-- Template boundary with DJB2 digest for hydration verification<!--lit-node N-->-- Marks elements with attribute bindings for hydration<!--lit-part-->value<!--/lit-part-->-- Child expression value boundariesdefer-hydration-- Added to nested custom elements inside shadow roots
cmd/golit/ CLI binary (bundle, compile, transform, render, serve, version)
pkg/jsengine/ QJS engine, esbuild bundler, DOM shim, template collector,
import map parser, module registry, shared runtime loader
pkg/transformer/ HTML file walker, component discovery, DSD expansion
github.com/evanw/esbuild-- JavaScript bundler (Go-native)github.com/fastschema/qjs-- QuickJS via WebAssembly (pure Go, no CGo)golang.org/x/net/html-- HTML5 parser
This project uses a Go-native changeset tool for version management and changelog generation. When making a notable change (new feature, bug fix, breaking change), add a changeset before merging:
go run ./tools/changesetThis prompts you to select the semver bump type (major/minor/patch) and write a short description. A markdown file is created in .changeset/ and committed with your PR.
Changes that don't warrant a release (docs, CI tweaks, refactoring with no public API impact) can skip this step.
Releases are automated via GitHub Actions:
- When PRs with changeset files are merged to
main, theprepare-releaseworkflow consumes changesets, bumps the version incmd/golit/version.go, updatesCHANGELOG.md, and opens (or updates) arelease/nextPR. - When you merge the release PR, the
releaseworkflow:- Cross-compiles binaries for Linux, macOS, and Windows (amd64 + arm64)
- Packages archives with third-party licenses and SHA-256 checksums
- Creates a GitHub Release with all artifacts
- Triggers a Homebrew tap update
make build # Build for current platform
make test # Run tests
make cross-compile # Build for all platforms (output in dist/)
make help # Show all targetsMIT