From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components
---
title: "From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components"
published: true
description: How I added SSR and hydration to the Gem Web Components library, built an app framework on Rsbuild, and benchmarked it against SvelteKit, Nuxt and Next.js.
tags: webcomponents, javascript, ssr, webdev
---
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 }));
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.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, '/');
},
};
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 = 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
- GitHub: mantou132/gem
- Gemkit docs: gemkit.gemjs.org
- Gem docs: gemjs.org
1 reaction
Mentions
Location: Changsha, China (UTC+8) Remote: Yes (remote only; async-friendly, happy to overlap a few hours with US/EU) Willing to relocate: No Technologies: TypeScript, JavaScript, React, Web Components, Node.js, Rust, WebAssembly, Tauri, WebExtensions, Chrome DevTools Protocol, MCP, WebRTC, PostgreSQL Résumé/CV: on request (email below) GitHub: github.com/mantou132 Email: 594mantou [at] gmail.com Senior frontend / full-stack engineer, 10 years. Lately focused on tooling for coding agents: - Browser4Agent (mantou132/browser4agent): lets Claude Code, Codex, Cursor etc. use your real, logged-in browser over MCP or a CLI. WebExtension + Rust native host, on the Chrome, Edge and Firefox stores. - AgentDeck: run ACP coding agents from your phone (Tauri 2 app + Rust daemon + end-to-end encrypted relay), on the App Store and Google Play. - Gem (mantou132/gem): a Web Components framework I designed and maintain, with streaming SSR and a Rust SWC plugin. - NESBox (463 stars): multiplayer retro gaming in the browser, Rust emulator compiled to WASM, frames streamed over WebRTC. Previously Senior Frontend Engineer at Dewu (Poizon), Frontend Lead at Duoyun, Ele.me (Alibaba). Looking for remote roles in frontend, full-stack or AI/agent tooling. I work best async and in writing.
用 Web Components 做了个全栈框架 Gemkit,首屏 JS 26KB, SSR 性能和 SvelteKit 差不多 几年前在这发过我写的 Web Components 库 Gem ( v2ex.com ),一直有个硬伤:只有客户端渲染。连它自带的文档站生成器 gem-book 也是 CSR ,搜索引擎基本收录不到啥东西。 之前在这问过 React SSR 和自定义元素怎么配合( v2ex.com ),当时我自己回复说「用 jsdom 模拟生成 ShadowDOM 理论上也行」。后来真这么干了: 1. 先用 jsdom 做原型,跑通了序列化(输出 Declarative Shadow DOM )和水合(复用服务端的 DOM ,不重建) 2. 再把 jsdom 去掉,直接渲染成字符串,用 AsyncLocalStorage 做请求隔离,支持流式输出,这就是 gem-ssr 3. 最近参考 Next.js 和 SvelteKit ,在 gem-ssr 上做了应用框架 Gemkit 长啥样 一个文件就是一个页面: // src/pages/blog/[slug].ts @page<{ slug: string }, Post>({ load: ({ params }) => getPost(params.slug), title: ({ data }) => data.title, }) export default class PostPage extends GemElement { @property data?: Post; @template() #content = () => html`<h1>${this.data?.title}</h1>`; } 只在服务端跑的代码放同名的 [slug].server.ts ,可以读 cookie 、redirect 、处理表单 actions ,不会打包到客户端。用过 SvelteKit 的应该很熟悉。 其他能力: - 文件路由、嵌套布局、i18n - 默认 SSG ,部署静态托管就行;需要的页面可以按请求渲染,也支持 ISR - @server() 元素只在服务端渲染,代码和依赖不进客户端;@lazy() 元素用户交互时才加载代码水合,第一次点击不会丢 - 本身就是个 Rsbuild 插件,Rsbuild 的东西照常用 - 内置 Vercel / Netlify / Cloudflare 适配 和其他框架比了下 用 RealWorld ( Medium 克隆)各实现了一遍,同一个后端、同一份数据: | | 首页 JS (gzip) | Lighthouse | FCP | SSR req/s | | --- | ---: | ---: | ---: | ---: | | Gemkit | 25.9 KB | 100 | 906 ms | 1382 | | SvelteKit | 44.0 KB | 100 | 1285 ms | 1437 | | Nuxt | 85.8 KB | 99 | 1656 ms | 1042 | | Next.js | 103.5 KB | 96 | 908 ms | 317 | SSR 吞吐 SvelteKit 略高一点,两个其实都快到后端的上限了。测试代码、复现步骤和原始数据都在这: github.com/mantou132/realworld/tree/framework-com… ,觉得测得不对的欢迎指出来。 不足 - 个人项目,还是 0.x ,没在大项目里用过 - 客户端导航是先改 URL 再渲染新页面,加载期间会看到新 URL + 旧页面,后面再改 试试 npm create gemkit@latest my-app - 文档: gemkit.gemjs.org - 仓库: mantou132/gem - 完整的过程写了篇文章(英文): dev.to 欢迎拍砖,觉得有意思的话点个 star 吧。
From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components --- title: "From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components" published: true description: How I added SSR and hydration to the Gem Web Components library, built an app framework on Rsbuild, and benchmarked it against SvelteKit, Nuxt and Next.js. tags: webcomponents, javascript, ssr, webdev --- 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 })); 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.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, '/'); }, }; 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 = 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 - GitHub: mantou132/gem - Gemkit docs: gemkit.gemjs.org - Gem docs: gemjs.org