DEV Community

mantou
mantou

Posted on

From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components

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 needs customElements, 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, location and history, 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 }));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

One file is one page

// src/pages/index.ts
@page('Home')
export default class HomePage extends GemElement {
  @template()
  #content = () => html`<h1>Hello Gemkit</h1>`;
}
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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') };
}
Enter fullscreen mode Exit fullscreen mode
// 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>`;
}
Enter fullscreen mode Exit fullscreen mode
  • First load: data is embedded in the HTML as JSON and used during hydration, no extra request
  • Client navigation: Gemkit fetches <page path>/__data.json with 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, '/');
  },
};
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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>`)}`;
}
Enter fullscreen mode Exit fullscreen mode

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 = false and are rendered per request by gemkit start
  • export const revalidate = 60 enables 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:

  1. jsdom prototype: serialization (Declarative Shadow DOM) and hydration without recreating the DOM, proving SSR works for Web Components
  2. gem-ssr: no jsdom, string rendering + request isolation + streaming
  3. 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)