<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mantou</title>
    <description>The latest articles on DEV Community by mantou (@mantou132).</description>
    <link>https://dev.to/mantou132</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F60488%2F618df924-a444-4ac4-9a25-09d47185b558.png</url>
      <title>DEV Community: mantou</title>
      <link>https://dev.to/mantou132</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mantou132"/>
    <language>en</language>
    <item>
      <title>From client-only to SSR: building Gemkit, a SvelteKit-style framework for Web Components</title>
      <dc:creator>mantou</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:06:21 +0000</pubDate>
      <link>https://dev.to/mantou132/from-client-only-to-ssr-building-gemkit-a-sveltekit-style-framework-for-web-components-8ag</link>
      <guid>https://dev.to/mantou132/from-client-only-to-ssr-building-gemkit-a-sveltekit-style-framework-for-web-components-8ag</guid>
      <description>&lt;p&gt;A long time ago I built &lt;a href="https://github.com/mantou132/gem" rel="noopener noreferrer"&gt;Gem&lt;/a&gt;, a library for building UI components and apps with Web Components. It was pleasant to use, but it had one big weakness: &lt;strong&gt;client-side rendering only&lt;/strong&gt;. Even GemBook, its documentation site generator, rendered everything in the browser, so search engines saw an almost empty HTML page.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;Gemkit&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SSR is hard for Web Components
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Elements are &lt;code&gt;class extends HTMLElement&lt;/code&gt;, which needs &lt;code&gt;customElements&lt;/code&gt;, &lt;code&gt;HTMLElement&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;...&lt;/li&gt;
&lt;li&gt;Styles and content live in Shadow DOM, which plain HTML could not express&lt;/li&gt;
&lt;li&gt;Gem templates are based on (a modified) lit-html, which renders real DOM nodes, not a virtual DOM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fortunately, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template#shadowrootmode" rel="noopener noreferrer"&gt;Declarative Shadow DOM&lt;/a&gt; is now supported by all major browsers: &lt;code&gt;&amp;lt;template shadowrootmode="open"&amp;gt;&lt;/code&gt; puts Shadow DOM directly in HTML. The remaining question was &lt;strong&gt;how to run Gem elements in Node.js&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prototype: serialization and hydration on jsdom
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Serialization.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hydration.&lt;/strong&gt; 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, &lt;strong&gt;it doesn't recreate the DOM&lt;/strong&gt;. Templates also carry a digest; on a mismatch, the client warns and re-renders only that part.&lt;/p&gt;

&lt;p&gt;That proved SSR with Web Components works. But jsdom was far from production ready:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;jsdom is heavy, every request goes through a full DOM implementation&lt;/li&gt;
&lt;li&gt;There is only one global &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;location&lt;/code&gt; and &lt;code&gt;history&lt;/code&gt;, so concurrent requests pollute each other&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  gem-ssr: dropping jsdom, isolating requests
&lt;/h2&gt;

&lt;p&gt;Next, I moved gem-ssr towards production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Render to strings without jsdom.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Request isolation.&lt;/strong&gt; With &lt;code&gt;AsyncLocalStorage&lt;/code&gt;, stores, &lt;code&gt;history&lt;/code&gt;, &lt;code&gt;location&lt;/code&gt; and the i18n language are scoped per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Streaming.&lt;/strong&gt; &lt;code&gt;render&lt;/code&gt; returns an async iterable. Content before pending async elements is sent first, and independent async elements are awaited in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mantou/gem-ssr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;app-root&amp;gt;&amp;lt;/app-root&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gemkit: the app framework for Gem
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/mantou132/gem/tree/main/packages/gemkit" rel="noopener noreferrer"&gt;Gemkit&lt;/a&gt; borrows heavily from Next.js and SvelteKit: file-based routing, layouts, &lt;code&gt;load&lt;/code&gt;, form actions, SSR/SSG/ISR. It is an &lt;a href="https://rsbuild.rs" rel="noopener noreferrer"&gt;Rsbuild&lt;/a&gt; plugin, so Rsbuild's config, dev server and plugins all keep working.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create gemkit@latest my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One file is one page
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/index.ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HomePage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GemElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;#content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;h1&amp;gt;Hello Gemkit&amp;lt;/h1&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;@customElement&lt;/code&gt; needed, the tag name comes from the file path, and Gem and common Gemkit APIs are auto-imported. This page is prerendered to &lt;code&gt;dist/web/index.html&lt;/code&gt; at build time and hydrated on the client.&lt;/p&gt;

&lt;p&gt;For Gemkit, &lt;code&gt;unplugin-gem&lt;/code&gt; also got an &lt;code&gt;ssr&lt;/code&gt; option: its swc plugin adds a key to module-level &lt;code&gt;createStore&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The project structure will look familiar if you know SvelteKit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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, &amp;lt;gemkit-outlet&amp;gt; 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/*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Data loading: isomorphic &lt;code&gt;load&lt;/code&gt; + server modules
&lt;/h3&gt;

&lt;p&gt;A page's &lt;code&gt;load&lt;/code&gt; is isomorphic: it runs during SSR and client navigation. Server-only code goes in a &lt;code&gt;*.server.ts&lt;/code&gt; file with the same name. It's never bundled for the client; importing it from client code fails the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/blog/[slug].server.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`content/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.md`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/blog/[slug].ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;page&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parseMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostPage&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GemElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;property&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;#content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;article .innerHTML=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;lt;/article&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;First load: data is embedded in the HTML as JSON and used during hydration, no extra request&lt;/li&gt;
&lt;li&gt;Client navigation: Gemkit fetches &lt;code&gt;&amp;lt;page path&amp;gt;/__data.json&lt;/code&gt; with the data of the page and all its layouts&lt;/li&gt;
&lt;li&gt;Prerendering: data files are written next to pages, so client navigation works on static hosting too&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Requests, cookies, redirects and forms
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;*.server.ts&lt;/code&gt;, &lt;code&gt;load&lt;/code&gt; can read the request and cookies and redirect in one line, and &lt;code&gt;actions&lt;/code&gt; handle form submissions, just like SvelteKit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/article/[slug].server.ts (from the RealWorld app)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`articles/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`articles/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/comments`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="c1"&gt;// markdown is converted on the server, marked and sanitize-html never reach the client&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sanitizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;deleteArticle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`articles/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;303&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API routes are plain &lt;code&gt;*.server.ts&lt;/code&gt; modules exporting &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, etc., with a typed &lt;code&gt;$gemkit/api&lt;/code&gt; client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server elements: islands with Web Components
&lt;/h3&gt;

&lt;p&gt;This is where Gemkit and Web Components fit together best. A page can mix three kinds of elements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decorator&lt;/th&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@server()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rendered&lt;/td&gt;
&lt;td&gt;no code, no hydration; code and dependencies stay out of the client bundle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@lazy({ on })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rendered&lt;/td&gt;
&lt;td&gt;shows server HTML, loads code and hydrates on interaction / visible / idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@client()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tag only&lt;/td&gt;
&lt;td&gt;renders normally, the module can use browser-only code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;customElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-like-button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interaction&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppLikeButtonElement&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GemElement&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default &lt;code&gt;@lazy&lt;/code&gt; loads on the first interaction, and the event that triggered loading (&lt;code&gt;click&lt;/code&gt;, &lt;code&gt;pointerdown&lt;/code&gt;, etc.) is re-dispatched to the original target, so &lt;strong&gt;the first click is not lost&lt;/strong&gt;. Rarely used parts like like-buttons and comment boxes don't cost first-load JS.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@server()&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;customElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-comments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppCommentsElement&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GemElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;#state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Comment&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Server: awaited before output, state is sent with the element&lt;/span&gt;
  &lt;span class="c1"&gt;// Client: restored on first load and skipped; runs when mounted after client navigation&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;server&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;#load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;#state&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchComments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;template&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;#content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;#state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rendering modes: SSG, SSR, ISR
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Every page is prerendered at build time by default (SSG) and can be deployed to any static host&lt;/li&gt;
&lt;li&gt;Pages that read cookies or the request export &lt;code&gt;prerender = false&lt;/code&gt; and are rendered per request by &lt;code&gt;gemkit start&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;export const revalidate = 60&lt;/code&gt; enables ISR: rendered and cached on the first request, then re-rendered in the background when stale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: links prefetch modules, data and server element HTML on hover; back/forward restores from a history cache without requests and restores scroll position; &lt;code&gt;src/locales&lt;/code&gt; generates locale-prefixed routes.&lt;/p&gt;

&lt;p&gt;GemBook now runs on Gemkit, so Gem's documentation sites finally have full SSR/SSG. The &lt;a href="https://gemkit.gemjs.org/en/" rel="noopener noreferrer"&gt;Gemkit docs&lt;/a&gt; are built with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  RealWorld comparison
&lt;/h2&gt;

&lt;p&gt;To check whether Gemkit holds up in a real app, I implemented &lt;a href="https://github.com/realworld-apps/realworld" rel="noopener noreferrer"&gt;RealWorld&lt;/a&gt; (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.&lt;/p&gt;

&lt;p&gt;Apple M2, 16 GB, Node v25.8.1, Lighthouse median of 5 runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client JS (gzip)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Home&lt;/th&gt;
&lt;th&gt;Article&lt;/th&gt;
&lt;th&gt;JS requests (home)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemkit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.9 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.9 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;44.0 KB&lt;/td&gt;
&lt;td&gt;43.3 KB&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;85.8 KB&lt;/td&gt;
&lt;td&gt;85.5 KB&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;103.5 KB&lt;/td&gt;
&lt;td&gt;126.5 KB&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse, home (mobile, simulated throttling)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;FCP&lt;/th&gt;
&lt;th&gt;LCP&lt;/th&gt;
&lt;th&gt;TBT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemkit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;906 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1361 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1285 ms&lt;/td&gt;
&lt;td&gt;1671 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;1656 ms&lt;/td&gt;
&lt;td&gt;1806 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;908 ms&lt;/td&gt;
&lt;td&gt;2022 ms&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;SSR throughput (autocannon, 20 connections, median of 5 rounds)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Home req/s&lt;/th&gt;
&lt;th&gt;Article req/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemkit&lt;/td&gt;
&lt;td&gt;1382&lt;/td&gt;
&lt;td&gt;1377&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1437&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1388&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;1042&lt;/td&gt;
&lt;td&gt;1169&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;317&lt;/td&gt;
&lt;td&gt;359&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Other&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Client navigation (home → article)&lt;/th&gt;
&lt;th&gt;Source lines&lt;/th&gt;
&lt;th&gt;Official e2e passed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemkit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;968&lt;/td&gt;
&lt;td&gt;84 / 91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;78 ms&lt;/td&gt;
&lt;td&gt;1143&lt;/td&gt;
&lt;td&gt;6 / 93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;95 ms&lt;/td&gt;
&lt;td&gt;711&lt;/td&gt;
&lt;td&gt;86 / 91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;86 ms&lt;/td&gt;
&lt;td&gt;2712&lt;/td&gt;
&lt;td&gt;60 / 91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How to read this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JS size&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse&lt;/strong&gt;: Gemkit and SvelteKit both score 100; Gemkit's FCP is about 400 ms faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR throughput&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;e2e&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building RealWorld was also a good round of dogfooding. It exposed and fixed several issues: &lt;code&gt;load&lt;/code&gt; couldn't access the request, query-only navigation didn't re-run &lt;code&gt;load&lt;/code&gt;, and children hydrating before their parents caused mismatches. CPU profiling found that every route match created a new &lt;code&gt;URLPattern&lt;/code&gt; (now cached, with plain string comparison for patterns without parameters) and an extra &lt;code&gt;TextEncoderStream&lt;/code&gt;. After those fixes, home page throughput went from 661 to about 1380 req/s.&lt;/p&gt;

&lt;p&gt;Code, reproduction steps and raw data are &lt;a href="https://github.com/mantou132/realworld/tree/framework-comparison/frameworks" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Corrections are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Gem went from client-only to a full SSR framework in three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;jsdom prototype&lt;/strong&gt;: serialization (Declarative Shadow DOM) and hydration without recreating the DOM, proving SSR works for Web Components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gem-ssr&lt;/strong&gt;: no jsdom, string rendering + request isolation + streaming&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemkit&lt;/strong&gt;: on top of gem-ssr and inspired by Next.js / SvelteKit, with file-based routing, layouts, &lt;code&gt;load&lt;/code&gt;/&lt;code&gt;actions&lt;/code&gt;, server elements, SSG/ISR and i18n&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create gemkit@latest my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/mantou132/gem" rel="noopener noreferrer"&gt;https://github.com/mantou132/gem&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gemkit docs: &lt;a href="https://gemkit.gemjs.org/en/" rel="noopener noreferrer"&gt;https://gemkit.gemjs.org/en/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gem docs: &lt;a href="https://gemjs.org" rel="noopener noreferrer"&gt;https://gemjs.org&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webcomponents</category>
      <category>javascript</category>
      <category>ssr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>gem-panel: easily create layout similar to Adobe After Effects</title>
      <dc:creator>mantou</dc:creator>
      <pubDate>Tue, 11 May 2021 01:33:07 +0000</pubDate>
      <link>https://dev.to/mantou132/easily-create-layout-similar-to-adobe-after-effects-139n</link>
      <guid>https://dev.to/mantou132/easily-create-layout-similar-to-adobe-after-effects-139n</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/mantou132/gem-panel"&gt;https://github.com/mantou132/gem-panel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Drag to adjust the grid&lt;/li&gt;
&lt;li&gt;Drag to move the panel and window&lt;/li&gt;
&lt;li&gt;Drag and drop to adjust the panel sorting&lt;/li&gt;
&lt;li&gt;Add commands to the panel&lt;/li&gt;
&lt;li&gt;Cache layout&lt;/li&gt;
&lt;li&gt;Custom style, &lt;a href="https://gem-panel-example-style.vercel.app/"&gt;example&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Async load panel content, &lt;a href="https://gem-panel-example-bridge.vercel.app/"&gt;example&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Typescript support&lt;/li&gt;
&lt;li&gt;Lightweight, ~20kb(br)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Layout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gem-panel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mantou/gem&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p1 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`p1 content`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p2 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p3 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;p3-panel&amp;gt;&amp;lt;/p3-panel&amp;gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p4 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;p4-panel&amp;gt;&amp;lt;/p4-panel&amp;gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p5 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;p5-panel&amp;gt;&amp;lt;/p5-panel&amp;gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panel6&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Panel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p6 title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;p6-panel&amp;gt;&amp;lt;/p6-panel&amp;gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;panels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;panel1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel6&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Layout&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;panel1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel5&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;panel2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;panel6&lt;/span&gt;&lt;span class="p"&gt;])]);&lt;/span&gt;

&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`
    &amp;lt;style&amp;gt;
      html,
      body {
        margin: 0;
        padding: 0;
        height: 100%;
      }
      gem-panel::part(fixed-window) {
        box-shadow: 0 0.3em 1em rgb(0 0 0 / 40%);
      }
    &amp;lt;/style&amp;gt;
    &amp;lt;gem-panel
      .panels=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;panels&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
      .layout=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
      .theme=&lt;/span&gt;&lt;span class="p"&gt;${{&lt;/span&gt; &lt;span class="nl"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;red&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;}
      cache
      cache-version="1"
    &amp;gt;&amp;lt;/gem-panel&amp;gt;
  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can use &lt;code&gt;&amp;lt;gem-panel&amp;gt;&lt;/code&gt; in other ways.&lt;/p&gt;

</description>
      <category>webcomponents</category>
      <category>layout</category>
      <category>customelements</category>
      <category>adobeaftereffects</category>
    </item>
    <item>
      <title>Gem-book: Quickly build a document website</title>
      <dc:creator>mantou</dc:creator>
      <pubDate>Fri, 27 Nov 2020 15:41:45 +0000</pubDate>
      <link>https://dev.to/mantou132/gem-book-quickly-build-a-document-website-49pk</link>
      <guid>https://dev.to/mantou132/gem-book-quickly-build-a-document-website-49pk</guid>
      <description>&lt;p&gt;Only one command is needed to build a markdown document into a website:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gem-book docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://gem-book.js.org/"&gt;See more&lt;/a&gt; options.&lt;/p&gt;

&lt;p&gt;The official website example of gem-book:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build:website"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gem-book docs --title Gem-book --icon logo.png --i18n --home-mode --plugins raw --build"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;netlify.toml&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Netlify deploy settings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--ddzcx5Sy--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/i/m70ci387hsc2m7m39hbh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--ddzcx5Sy--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/i/m70ci387hsc2m7m39hbh.png" alt="Netlify"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docs</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
