A long time ago I built Gem, a library for building UI components and apps with Web Components. It was pleasant to use, but it had one big weakness: client-side rendering only. Even GemBook, its documentation site generator, rendered everything in the browser, so search engines saw an almost empty HTML page.
This post is about how I brought Gem to the server: first a gem-ssr prototype on jsdom that could serialize and hydrate, then moving away from jsdom and isolating requests, and finally Gemkit, an app framework inspired by Next.js and SvelteKit. At the end, I compare it with SvelteKit, Nuxt and Next.js using the RealWorld app.
Why SSR is hard for Web Components
React and Vue components are framework abstractions, so the server can turn a virtual DOM into a string. Web Components are different, they depend on the browser:
- Elements are
class extends HTMLElement, which needscustomElements,HTMLElement,document... - Styles and content live in Shadow DOM, which plain HTML could not express
- Gem templates are based on (a modified) lit-html, which renders real DOM nodes, not a virtual DOM
Fortunately, Declarative Shadow DOM is now supported by all major browsers: <template shadowrootmode="open"> puts Shadow DOM directly in HTML. The remaining question was how to run Gem elements in Node.js.
The prototype: serialization and hydration on jsdom
The most direct idea: Gem needs a DOM, so give it one. The first version of gem-ssr used jsdom to emulate the browser and got the core of SSR working:
1. Serialization. Elements are created and rendered on the server, then serialized to HTML together with their Shadow DOM, output as Declarative Shadow DOM. The browser can display the page right away.
2. Hydration. The server output contains comment markers for each template binding. When an element renders on the client for the first time, it uses the markers to connect bindings to the existing DOM. It only attaches event listeners and sets properties, it doesn't recreate the DOM. Templates also carry a digest; on a mismatch, the client warns and re-renders only that part.
That proved SSR with Web Components works. But jsdom was far from production ready:
- jsdom is heavy, every request goes through a full DOM implementation
- There is only one global
document,locationandhistory, so concurrent requests pollute each other
gem-ssr: dropping jsdom, isolating requests
Next, I moved gem-ssr towards production:
1. Render to strings without jsdom. gem-ssr provides only the minimal DOM Gem needs and compiles templates to strings on the server. The serialization and hydration protocol stays the same.
2. Request isolation. With AsyncLocalStorage, stores, history, location and the i18n language are scoped per request.
3. Streaming. render returns an async iterable. Content before pending async elements is sent first, and independent async elements are awaited in parallel:
import { render } from '@mantou/gem-ssr';
const stream = ReadableStream.from(render(html`<app-root></app-root>`, { url: req.url }));
At this point gem-ssr could run in a real server. But routing, data loading, forms and deployment still had to be assembled by every project. That's what Gemkit is for.
Gemkit: the app framework for Gem
Gemkit borrows heavily from Next.js and SvelteKit: file-based routing, layouts, load, form actions, SSR/SSG/ISR. It is an Rsbuild plugin, so Rsbuild's config, dev server and plugins all keep working.
npm create gemkit@latest my-app
One file is one page
// src/pages/index.ts
@page('Home')
export default class HomePage extends GemElement {
@template()
#content = () => html`<h1>Hello Gemkit</h1>`;
}
No @customElement needed, the tag name comes from the file path, and Gem and common Gemkit APIs are auto-imported. This page is prerendered to dist/web/index.html at build time and hydrated on the client.
For Gemkit, unplugin-gem also got an ssr option: its swc plugin adds a key to module-level createStore calls based on the file path and variable name, so client and server builds agree. Stores updated during server rendering are output by key, and the client uses those values, so the first render matches the HTML.
The project structure will look familiar if you know SvelteKit:
src/
app.ts global styles, stores
app.server.ts handle: runs before each server request
elements/ app-* elements, auto-imported
pages/
_layout.ts root layout, <gemkit-outlet> renders child routes
_error.ts error page / 404
index.ts /
blog/
_layout.ts nested layout, kept while navigating within /blog/*
[slug].ts /blog/:slug
[slug].server.ts server-only load / actions
[...rest].ts /blog/*
locales/
en.json default language
zh.json /zh/*
Data loading: isomorphic load + server modules
A page's load is isomorphic: it runs during SSR and client navigation. Server-only code goes in a *.server.ts file with the same name. It's never bundled for the client; importing it from client code fails the build:
// src/pages/blog/[slug].server.ts
import { readFile } from 'node:fs/promises';
export async function load({ params }) {
return { markdown: await readFile(`content/${params.slug}.md`, 'utf8') };
}
// src/pages/blog/[slug].ts
@page<{ slug: string }, { markdown: string }>({
load: ({ data }) => parseMarkdown(data.markdown),
title: ({ data }) => data.title,
})
export default class PostPage extends GemElement {
@property data?: { html: string };
@template()
#content = () => html`<article .innerHTML=${this.data?.html}></article>`;
}
- First load: data is embedded in the HTML as JSON and used during hydration, no extra request
- Client navigation: Gemkit fetches
<page path>/__data.jsonwith the data of the page and all its layouts - Prerendering: data files are written next to pages, so client navigation works on static hosting too
Requests, cookies, redirects and forms
In *.server.ts, load can read the request and cookies and redirect in one line, and actions handle form submissions, just like SvelteKit:
// src/pages/article/[slug].server.ts (from the RealWorld app)
export async function load(ctx) {
const [{ article }, { comments }] = await Promise.all([
api(ctx, `articles/${ctx.params.slug}`),
api(ctx, `articles/${ctx.params.slug}/comments`),
]);
// markdown is converted on the server, marked and sanitize-html never reach the client
return { article: { ...article, body: sanitizeHtml(await marked(article.body)) }, comments };
}
export const actions = {
deleteArticle: async (ctx) => {
await api(ctx, `articles/${ctx.params.slug}`, 'DELETE');
redirect(303, '/');
},
};
API routes are plain *.server.ts modules exporting GET, POST, etc., with a typed $gemkit/api client.
Server elements: islands with Web Components
This is where Gemkit and Web Components fit together best. A page can mix three kinds of elements:
| Decorator | Server | Client |
|---|---|---|
@server() |
rendered | no code, no hydration; code and dependencies stay out of the client bundle |
@lazy({ on }) |
rendered | shows server HTML, loads code and hydrates on interaction / visible / idle |
@client() |
tag only | renders normally, the module can use browser-only code |
@customElement('app-like-button')
@lazy({ on: 'interaction' })
class AppLikeButtonElement extends GemElement {}
By default @lazy loads on the first interaction, and the event that triggered loading (click, pointerdown, etc.) is re-dispatched to the original target, so the first click is not lost. Rarely used parts like like-buttons and comment boxes don't cost first-load JS.
@server() can also mark a method or field. It runs during SSR, the resulting state is output with the element and restored during hydration, and the first run on the client is skipped, so data isn't fetched twice:
@customElement('app-comments')
class AppCommentsElement extends GemElement {
#state = createState({ comments: [] as Comment[] });
// Server: awaited before output, state is sent with the element
// Client: restored on first load and skipped; runs when mounted after client navigation
@server()
@mounted()
#load = async () => this.#state({ comments: await fetchComments() });
@template()
#content = () => html`${this.#state.comments.map((c) => html`<p>${c.text}</p>`)}`;
}
Rendering modes: SSG, SSR, ISR
- Every page is prerendered at build time by default (SSG) and can be deployed to any static host
- Pages that read cookies or the request export
prerender = falseand are rendered per request bygemkit start -
export const revalidate = 60enables ISR: rendered and cached on the first request, then re-rendered in the background when stale
Also: links prefetch modules, data and server element HTML on hover; back/forward restores from a history cache without requests and restores scroll position; src/locales generates locale-prefixed routes.
GemBook now runs on Gemkit, so Gem's documentation sites finally have full SSR/SSG. The Gemkit docs are built with it.
RealWorld comparison
To check whether Gemkit holds up in a real app, I implemented RealWorld (Conduit, a Medium clone) and compared it with the SvelteKit, Nuxt and Next.js implementations on the same local backend, seed data and theme CSS.
Apple M2, 16 GB, Node v25.8.1, Lighthouse median of 5 runs.
Client JS (gzip)
| Home | Article | JS requests (home) | |
|---|---|---|---|
| Gemkit | 25.9 KB | 25.9 KB | 2 |
| SvelteKit | 44.0 KB | 43.3 KB | 23 |
| Nuxt | 85.8 KB | 85.5 KB | 15 |
| Next.js | 103.5 KB | 126.5 KB | 8 |
Lighthouse, home (mobile, simulated throttling)
| Score | FCP | LCP | TBT | |
|---|---|---|---|---|
| Gemkit | 100 | 906 ms | 1361 ms | 0 |
| SvelteKit | 100 | 1285 ms | 1671 ms | 0 |
| Nuxt | 99 | 1656 ms | 1806 ms | 0 |
| Next.js | 96 | 908 ms | 2022 ms | 4 |
SSR throughput (autocannon, 20 connections, median of 5 rounds)
| Home req/s | Article req/s | |
|---|---|---|
| Gemkit | 1382 | 1377 |
| SvelteKit | 1437 | 1388 |
| Nuxt | 1042 | 1169 |
| Next.js | 317 | 359 |
Other
| Client navigation (home → article) | Source lines | Official e2e passed | |
|---|---|---|---|
| Gemkit | 53 ms | 968 | 84 / 91 |
| SvelteKit | 78 ms | 1143 | 6 / 93 |
| Nuxt | 95 ms | 711 | 86 / 91 |
| Next.js | 86 ms | 2712 | 60 / 91 |
How to read this:
- JS size: Gemkit's home page ships about 59% of SvelteKit's JS, 30% of Nuxt's and 25% of Next's. Web Components are a browser feature, so the framework runtime is small.
- Lighthouse: Gemkit and SvelteKit both score 100; Gemkit's FCP is about 400 ms faster.
- SSR throughput: Gemkit and SvelteKit are roughly even (SvelteKit is 1–4% higher), 18–33% above Nuxt and about 4x Next. Both are close to the shared backend's limit; Gemkit spent about 44% of the time waiting for the backend.
- e2e: the remaining Gemkit failures are mostly environment issues (slow external images, the backend only returning popular tags). The SvelteKit and Next community implementations predate the e2e conventions (different routes), so their low pass rates don't mean missing features.
Building RealWorld was also a good round of dogfooding. It exposed and fixed several issues: load couldn't access the request, query-only navigation didn't re-run load, and children hydrating before their parents caused mismatches. CPU profiling found that every route match created a new URLPattern (now cached, with plain string comparison for patterns without parameters) and an extra TextEncoderStream. After those fixes, home page throughput went from 661 to about 1380 req/s.
Code, reproduction steps and raw data are on GitHub. Corrections are welcome.
Summary
Gem went from client-only to a full SSR framework in three steps:
- jsdom prototype: serialization (Declarative Shadow DOM) and hydration without recreating the DOM, proving SSR works for Web Components
- gem-ssr: no jsdom, string rendering + request isolation + streaming
-
Gemkit: on top of gem-ssr and inspired by Next.js / SvelteKit, with file-based routing, layouts,
load/actions, server elements, SSG/ISR and i18n
The result is a framework built on web standards, with ~26 KB of first-load JS and performance on par with SvelteKit. If you're interested in Web Components, give it a try:
npm create gemkit@latest my-app
- GitHub: https://github.com/mantou132/gem
- Gemkit docs: https://gemkit.gemjs.org/en/
- Gem docs: https://gemjs.org
Top comments (0)