Skip to content

Chapter 66: Server Rendering and Progressive Enhancement

Part VII is about production. The architecture from Parts IV and V is implemented; Part VI ships the maintained version as Kitsune; this part is about what running the architecture in real applications actually requires.

The first chapter is about server rendering. The Kit architecture has been deliberately compatible with server-rendered HTML throughout — the metadata protocol works on plain attributes, the boundary system reads context from the DOM tree, the runtime attaches to whatever markup is already there. This chapter makes that compatibility explicit and argues that server rendering should be the default for most applications using the architecture, not an opt-in.

The chapter also engages with the hydration problem (Chapter 15 introduced) and proposes attachment as the alternative — the Kitsune model where the runtime decorates server-rendered HTML rather than reconstructing it. Attachment is meaningfully different from React-style hydration and is what makes the architecture’s progressive-enhancement story actually work.

For most of the SPA era (Chapter 11 through Chapter 18), server rendering was the awkward retrofit. The application’s center of gravity was the client; the server’s job was returning JSON. When initial-load performance, SEO, and social-link previews became unavoidable concerns, the field added server rendering back — but as a layer on top of the SPA, with hydration as the bridge between the server-rendered HTML and the client-side framework’s component tree.

The meta-framework chapter (Chapter 15) traced this. Next.js, Remix, SvelteKit, Astro, SolidStart all evolved different answers to the how do we get the server and the client to cooperate? question, with varying levels of hydration cost, varying tolerance for divergence between server and client output, and varying degrees of complexity in the resulting architecture.

The Kit architecture turns the question around. Server rendering isn’t a retrofit; it’s the default. The application’s HTML can be produced by any server-side templating system the team prefers. The Kit runtime attaches to that HTML and adds coordination on top. No hydration mismatch. No double-rendering. No reconstructing the component tree.

The pattern works because the architecture’s components are real platform elements. A <kit-button> rendered by the server is the same element as a <kit-button> rendered by the client. The browser doesn’t have to be told this is actually a custom element — it knows, because custom elements are part of the platform. When the runtime loads and customElements.define('kit-button', KitButton) runs, the existing instances upgrade automatically. The HTML was already valid; the upgrade enhances it.

The distinction is worth landing precisely.

Hydration (React’s model, used by Next.js, Remix, and most of the React-based meta-frameworks): the server renders HTML. The client downloads the same component code the server used. The client runs the components against the server-rendered DOM, attaching event listeners and reconstructing the framework’s internal state. If anything in the server’s output doesn’t exactly match what the client would produce, the result is a hydration error — visible warnings in the console, sometimes visible flicker, sometimes broken interactivity.

The hydration model has costs. The framework’s component code has to download and execute on every page load before the page becomes interactive. The server and client have to produce exactly the same output (a requirement that has historically been hard to maintain). The browser does the work of constructing the framework’s parallel reality on top of the DOM that’s already there.

Attachment (the Kitsune model): the server renders HTML using whatever templating system the team uses. The client downloads the runtime. The runtime attaches a metadata-boundary listener to the page’s root. The runtime installs the application’s modules. No component-tree reconstruction. No matching requirement. The HTML is the application; the runtime is the coordination layer.

The attachment model is simpler. The cost is smaller. The compatibility surface with the server is the DOM, not the framework’s internal state. Server output doesn’t have to match anything — it just has to be valid HTML with the metadata protocol applied.

What works under attachment:

  • A form with data-meta-event="signup.attempted" is observed by the metadata boundary when submitted, regardless of whether the form was server-rendered or client-rendered.
  • A <kit-button> server-rendered with attributes upgrades to a custom element when the runtime loads. Before the upgrade, the element is a plain unknown element that the browser renders as a generic container; the slotted text content is visible. After the upgrade, the element gains its full behavior.
  • A <kit-boundary> element server-rendered with surface and feature attributes immediately supplies context to events fired inside it, even before the custom-element class is registered (because the metadata boundary’s collection algorithm reads attributes directly, not through the custom-element’s class).

The attachment model doesn’t eliminate every challenge of server rendering. Components that depend on browser-only APIs (localStorage, window, document.body) have to be careful when running on the server. The View Transitions API doesn’t work on the server (the animation happens in the browser when the page loads). Client-only state — the dialog’s open state, the user’s draft input — has to live in storage or be rehydrated on load. But the attachment model doesn’t fight the server-rendering layer the way hydration does.

Progressive Enhancement as a Real Strategy

Section titled “Progressive Enhancement as a Real Strategy”

Progressive enhancement is one of those terms that gets used in different ways. The Kit architecture commits to a specific version: the application works without JavaScript and works better with JavaScript.

Concretely:

  • Forms submit natively. The form’s action attribute points at a real server endpoint. Submitting without JavaScript produces a real POST request the server handles. With JavaScript, the metadata boundary observes the submit and additionally fires a runtime event; the form might or might not also have its native submit prevented (a data-meta-prop-prevent-default flag controls this).

  • Links navigate normally. <a href> elements are real anchors. Without JavaScript, clicking them produces a real navigation. With JavaScript, the View Transitions API (Chapter 28) animates the navigation. No client-side router is required (Chapter 26).

  • Buttons activate normally. <button type="submit"> inside a form submits the form. <button> elsewhere fires its click event. The runtime observes the event when the metadata is present; without the runtime, the click still happens (it just doesn’t reach the runtime’s modules).

  • Content is accessible to crawlers. The server-rendered HTML includes the page’s actual content. Search engines index it. Social-media link previews read it. Accessibility tools parse it. The content doesn’t depend on JavaScript to exist.

This is what enhancement means. The platform provides the baseline. JavaScript adds capabilities the baseline doesn’t have. If JavaScript fails to load, fails to parse, fails to execute, or fails to install — the page still works.

The cost is design discipline. Every interactive surface has to be designed first as something that works without JavaScript, and then enhanced with JavaScript. A Save button has to start as a form submit, then become a runtime event when JavaScript is available. A Delete action has to start as a server-handled link or form, then become a command with optimistic UI. A toggle has to start as a form-state change submitted to the server, then become a client-side state change persisted to storage.

For applications where this discipline fits — content sites, e-commerce, applications with shareable URLs, applications where users may have slow networks or older devices — the progressive-enhancement pattern produces more resilient, more accessible, more durable interfaces. For applications where the discipline doesn’t fit (real-time editors, complex interactive tools), the enhancement model can be relaxed selectively; the architecture supports both.

The Kit architecture doesn’t dictate which server-side technology renders the HTML. Any technology that produces valid HTML with the metadata protocol applied is compatible.

Rails (Ruby on Rails) has its View Components, Hotwire (Stimulus + Turbo), and ERB templates. A Rails view can include Kit components directly:

<%= form_with url: profile_path, method: :patch, html: { 'data-meta-event': 'profile.save_requested' } do |form| %>
<kit-field label="Display name">
<kit-text-field name="displayName" value="<%= @profile.display_name %>"></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Save</kit-button>
<% end %>

The Rails server renders the HTML with the metadata protocol applied. The Kit runtime, loaded on the page, attaches when the document is ready. The form submits normally when JavaScript is off; the metadata boundary observes it when JavaScript is on. Progressive enhancement works out of the box.

Django (Python) uses its template language similarly:

<form action="{% url 'profile_save' %}" method="post"
data-meta-event="profile.save_requested">
{% csrf_token %}
<kit-field label="Display name">
<kit-text-field name="displayName" value="{{ profile.display_name }}"></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Save</kit-button>
</form>

The pattern is the same. Server renders the HTML; Kit attaches and enhances.

Phoenix (Elixir) with LiveView is interesting because LiveView itself implements its own attachment-like model — the server renders, the client connects via WebSocket, the server sends diffs that the client applies. Kit components compose inside LiveView templates without conflicting; the components have their own lifecycle, LiveView has its own update model, and the two coexist.

Laravel (PHP) with Blade templates follows the same pattern as Rails and Django.

Astro (the meta-framework Chapter 15 covered) is particularly well-aligned with the Kit architecture. Astro’s islands model — render mostly static HTML on the server, with interactive components selectively hydrated — fits the architecture’s posture exactly. The Astro page can include Kit components directly:

---
const profile = await loadProfile()
---
<kit-boundary surface="profile-editor" feature="account">
<h1>Profile</h1>
<form data-meta-event="profile.save_requested" method="POST" action="/profile/save">
<kit-text-field name="displayName" value={profile.displayName}></kit-text-field>
<kit-button type="submit" variant="primary">Save</kit-button>
</form>
</kit-boundary>

Astro renders the page server-side. The Kit components are included in the output. The runtime loads on the client (Astro’s client: directives can control this). The architecture works.

The pattern across all of these is that the server’s templating system doesn’t matter. The Kit architecture’s substrate is the DOM. Anything that produces a DOM works. The team can pick the server-side technology that fits their broader stack — including not using a JavaScript-based meta-framework at all.

The boundary system from Chapter 41 works as well with server-rendered context as with client-attached context. The server includes the relevant attributes:

<kit-boundary
surface="profile-editor"
feature="account"
entity-type="profile"
entity-id="<%= @user.id %>"
data-meta-route="/profile/<%= @user.id %>"
>
<!-- form content -->
</kit-boundary>

The server has the data the boundary needs. The server includes it in the markup. The client’s runtime reads it at event time. The boundary context doesn’t need to be reconstructed; it was always there.

This is the architectural payoff of the attributes as protocol surface principle (Chapter 22). The server can participate in the protocol just by producing the right attributes. The client doesn’t need to do anything special; the metadata boundary’s context-walk algorithm reads the attributes from the DOM, regardless of how the DOM was constructed.

Custom Elements and Declarative Shadow DOM

Section titled “Custom Elements and Declarative Shadow DOM”

A specific platform feature that helps server rendering of custom elements: Declarative Shadow DOM.

The platform’s shadow DOM was originally only constructable from JavaScript (element.attachShadow({...})). This was a problem for server rendering — a server couldn’t produce a fully rendered custom element with its shadow content; the shadow content would only appear once the client’s JavaScript ran.

Declarative Shadow DOM (shipping reliably in 2023–2024) lets the server include the shadow content in the HTML directly:

<kit-card>
<template shadowrootmode="open">
<style>
.card { padding: 1rem; background: white; border-radius: 8px; }
</style>
<div class="card">
<slot></slot>
</div>
</template>
<p>Card content goes here (in the light DOM, slotted into the card).</p>
</kit-card>

The <template shadowrootmode="open"> tells the browser to attach the template’s content as the shadow root of the parent element. The browser handles this without JavaScript. The custom element’s class, when it eventually loads, sees that the shadow root already exists and doesn’t recreate it.

For server-rendered custom elements, this means the visual presentation is correct from the very first paint. No flash of unstyled content. No invisible component waiting for JavaScript. The element looks right immediately, behaves correctly once the runtime loads, and the user sees no transition.

Lit supports declarative shadow DOM through @lit-labs/ssr — the Lit team’s server-side rendering package, which renders Lit components to HTML including the declarative shadow DOM markup. For applications that ship Kit components and need server rendering, @lit-labs/ssr is the standard tool.

The server doesn’t have to be a traditional origin server. Cloudflare Workers, Vercel Edge functions, AWS Lambda@Edge, Fastly Compute@Edge — all of these run code close to the user, with low latency, and can render HTML on demand.

Edge rendering combines with the Kit architecture cleanly because the architecture is small. The runtime is under 30 KB. The component library scales with the application. There’s no large bundler-built JavaScript blob that has to be uploaded to every edge location.

A typical edge-rendered Kit application:

  • The edge worker handles the HTTP request.
  • The worker constructs the page’s HTML — possibly using Lit’s @lit-labs/ssr to render Kit components with declarative shadow DOM included.
  • The worker returns the HTML response with appropriate cache headers.
  • The client receives the HTML, parses it, displays it.
  • The runtime script (a small file, cacheable at the CDN level) loads asynchronously.
  • The runtime attaches to the DOM and the application’s interactivity becomes available.

The latency profile is good. The initial paint is fast (real HTML returned quickly). The runtime load is lazy. The architecture composes with the modern edge-rendering infrastructure rather than fighting it.

A specific architectural claim worth landing: the runtime is an enhancement layer.

Without the runtime, the page is functional. Forms submit. Links navigate. Native controls work. Server-rendered content is visible. The user can complete most tasks the application supports.

With the runtime, the page becomes richer. The metadata boundary observes interactions and fires runtime events. Capability modules respond — analytics tracks, audit records, observability traces. Custom elements upgrade with their full Lit-authored behavior. Storage-backed state propagates across tabs. View Transitions animate page changes.

The runtime doesn’t replace the page; it adds capabilities. If the runtime fails to load (slow network, broken CDN, blocked by the user’s browser), the page degrades to the no-JavaScript baseline. The baseline works. The user gets a functional, accessible, complete experience without the enhancement.

This is the progressive enhancement is a real strategy claim made concrete. The page works at every level — without HTML (unreachable), without CSS (functional, ugly), without JavaScript (functional, missing enhancements), without the runtime specifically (functional, missing the application’s coordination layer). Each layer adds value; missing layers don’t break the layers below.

The next chapter (Chapter 67) covers performance — Core Web Vitals, real user monitoring, the performance implications of the architecture’s choices. The platform-first approach has a strong performance story (small bundles, fast initial paint, no hydration cost), but the story has to be made specific with measurements and metrics.

Exercise: Build a Progressively-Enhanced Settings Page

Section titled “Exercise: Build a Progressively-Enhanced Settings Page”

Build a settings page that works without JavaScript and is enhanced when JavaScript loads.

Server-side: produce HTML for a profile-editor form. Use any server technology you’re comfortable with (Astro, Rails, Django, Phoenix, or plain HTML if you want to skip the server). Include:

<form
method="POST"
action="/settings/profile"
data-meta-event="profile.save_requested"
>
<kit-field label="Display name" required>
<kit-text-field name="displayName" value="Jeremy" required></kit-text-field>
</kit-field>
<kit-field label="Email" required>
<kit-text-field name="email" type="email" value="..." required></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Save</kit-button>
</form>

Test the no-JavaScript path:

  1. Disable JavaScript in your browser.
  2. Reload the page. Verify the form renders and the labels are visible.
  3. Submit the form. Verify the request goes to /settings/profile and the server’s response is shown.
  4. The form should be fully usable — fields editable, validation visible, submission working.

Test the JavaScript-enhanced path:

  1. Re-enable JavaScript.
  2. Verify the Kit components upgrade (the kit-text-field styling becomes the full styled version).
  3. Submit the form. Verify the metadata-boundary listener observes the submit. A subscribed module should log profile.save_requested with the form data.
  4. Decide whether to prevent the default form submission (so the JavaScript handles the save) or let it happen alongside (the JavaScript observes; the server still handles the save normally).

Then experiment:

  1. Throttle the network in dev tools (3G). How long until the page is interactive? How long until the runtime loads?
  2. Block the runtime script. The page should still work fully without it.
  3. Add <template shadowrootmode="open"> for the Kit components’ shadow content. Verify the first paint includes the component’s full styling.
  4. Test with a screen reader. Verify the page is navigable in both the no-JS and JS-enhanced paths.

Reflect on:

  1. Which features required JavaScript? (Probably: the metadata-boundary capture, the runtime modules, any client-side validation that goes beyond required/type/pattern.)
  2. Which features worked without JavaScript? (Probably: the form, the navigation, the visual presentation.)
  3. How does this approach compare to a typical Next.js or SvelteKit application? (Faster initial paint, more resilient to failures, smaller bundle, less framework code, simpler debugging.)
  4. What would a real product team prioritize first — the no-JS baseline, or the JS enhancement? (Depends on the audience; both should work, and the team should test both paths.)

The progressive-enhancement story is the architectural payoff of the platform is the substrate. The page works because the platform’s primitives work. The enhancement is a decoration on top. Each layer is independently useful, and the cumulative effect is a more durable, more accessible, more performant application than the framework-everywhere alternative.