This is Part 2 of a three-part series. Part 1 made the case for web components and Lit over metaframeworks; Part 3 tours five real apps built this way.

Strip away the marketing and a metaframework sells you exactly one thing: rendering strategy.

Where does your HTML come from — a build step, a server, or the browser? Who decides per page? How does server HTML become an interactive page without re-rendering everything? That’s the product. The router, the data conventions, the deploy adapters — all packaging around those three questions.

So let’s answer the three questions directly. This post is the longest of the series because it’s the load-bearing one: if rendering works without the framework, the framework is packaging.


The Three Modes, Honestly Compared

Before comparing, let’s define each mode properly — what actually happens, step by step.

CSR — client-side rendering. The server sends a nearly empty HTML shell — a <div id="app"> and a script tag — and JavaScript builds the entire page in the browser. Every SPA works this way. The sequence: download HTML (instant, it’s empty) → download the JS bundle → execute it → fetch data → finally render something. First paint waits on that whole chain, which is why slow connections stare at spinners, and why crawlers that don’t execute JavaScript see nothing. But once loaded, the app is fully alive: every interaction is local, instant, no server round-trips for UI.

SSR — server-side rendering. A server runs your components per request and sends fully-formed HTML — real headings, real product names, real prices, right there in the response body. The browser paints meaningful content on the very first network round-trip, before any JavaScript arrives. Crawlers see everything. Content can be personalized per user, per request. The costs: you need a running server (not just a CDN), and you need hydration — the process where client JavaScript attaches to the server’s HTML and makes it interactive without re-rendering it. Hydration is where frameworks hide their deepest magic; we’ll do it in the open below.

SSG — static site generation. The same rendering as SSR, but executed once at build time instead of per request. You run the renderer over every route, write the resulting HTML to files, and deploy the files to any static host or CDN edge — no server process at all. Delivery is as fast as the web gets, hosting is nearly free, and nothing can crash at 2am. The tradeoff: the content is frozen at build time. Every visitor sees the same page until you rebuild — perfect for marketing pages, docs, and blogs; wrong for a cart or a live price.

CSR SSR SSG
First paint Slow (waits on JS) Fast Fastest
SEO / crawlers Fragile Excellent Excellent
Personalized content Yes Yes No
Server required No Yes No
Content freshness Live Live Build-time
Best for Auth’d dashboards Catalogs, feeds Marketing, docs, blogs

Here’s the thing every experienced team eventually learns: a real application wants all three. The marketing pages want SSG. The product catalog wants SSR. The logged-in account area wants CSR. Metaframeworks won because they let you mix modes per route — that’s the actual moat.

So that’s the bar: per-route mode mixing, one component set. Let’s clear it with a ~5KB library and the platform.


The Foundation: Components That Render to Strings

Everything hangs on one capability: rendering a Lit component to HTML without a browser. That’s @lit-labs/ssr — it executes your templates in Node and emits the Declarative Shadow DOM markup from Part 1:

import { render } from '@lit-labs/ssr';
import { html } from 'lit';

const stream = render(html`
  <product-page .productId=${id}></product-page>
`);
// → an async iterable of HTML chunks, shadow roots and all

Two properties of that output matter enormously:

  1. It’s a stream. The server sends the <head> and the top of the page while it’s still rendering the bottom. The browser starts painting and fetching assets immediately — streaming SSR isn’t a premium framework feature, it’s just what string-iterables do.
  2. It paints without JavaScript. Because the output is Declarative Shadow DOM, the browser renders the components — encapsulated styles and all — straight off the parser. JS failed to load? Slow connection? The page is still readable and navigable, because real links and real forms work without hydration. Progressive enhancement isn’t a bolt-on; it’s the default.

The Route Table: One Source of Truth

Now the piece metaframeworks make proprietary — routing — as a plain data structure:

// src/routes.ts — the only routing config in the entire app
export const routes = [
  { pattern: '/',            mode: 'ssg', page: 'home-page' },
  { pattern: '/docs/:slug',  mode: 'ssg', page: 'docs-page' },
  { pattern: '/products/:id', mode: 'ssr', page: 'product-page' },
  { pattern: '/account/*',   mode: 'csr', page: 'account-shell' },
] as const;

Patterns are URLPattern syntax — a web standard, not a framework convention. And because the table is data, every consumer reads the same source of truth:

  • The SSG build iterates entries marked ssg, runs the SSR renderer over each (expanding dynamic params from your data), and writes static HTML files. Static generation is just server rendering at build time — same renderer, different moment.
  • The SSR server (a few dozen lines of Hono or plain node:http) matches ssr routes per request and streams the render.
  • The client router (~80 lines over URLPattern + the Navigation API) intercepts same-origin navigation, swaps the outlet component, and fires a View Transition for free. csr routes render entirely in-browser.

That’s the entire “metaframework.” One table, one component set, three modes — and changing a page from SSR to SSG is a one-word edit.


Hydration: The Part Frameworks Hide From You

Server HTML is inert until the client claims it. This is where frameworks bury their magic, and where doing it in the open teaches you the most.

Lit’s model is refreshingly mechanical. The client loads component definitions with hydration support, and each server-rendered component adopts its existing shadow DOM instead of re-rendering it — Lit walks the markers in the template, wires up the bindings, and from then on updates flow normally. No flash, no re-paint, no throwing away the server’s work.

The interesting decisions sit on top:

Don’t hydrate everything — use islands. Most of a content page never needs JavaScript. Mark components with defer-hydration and wake them on triggers you choose:

<theme-toggle defer-hydration data-wake="idle"></theme-toggle>
<site-search defer-hydration data-wake="focus"></site-search>

The theme toggle hydrates when the browser is idle. Search hydrates when focused — and a well-built island replays the keystroke that woke it, so even the user who types instantly loses nothing. A page of mostly-static content ships ~6 KB of entry JavaScript this way. That’s not a typo, and it’s not a benchmark stunt — it’s a real app you’ll meet in Part 3.

Serialize state once, carefully. The server already fetched the product data; the client shouldn’t fetch it again just to hydrate. Emit it as a data script:

<script type="application/json" id="__DATA__">{"product":{"id":"kb-01",...}}</script>

…and treat that emission as a security boundary: JSON in a script context must be escaped so a product description containing </script> can’t break out into markup. (My skill makes this a hard rule with a tested helper — this exact class of XSS is decades old and still everywhere.)

Align server and client first-render. Async components render as INITIAL on the server but often PENDING on the client’s first pass — if those templates differ, hydration mismatches and re-renders. Little disciplines like this are what a framework’s magic actually consists of. There’s not that much of it. It fits in your head.


What the Skill Automates

All of the above is exactly the kind of knowledge that’s simple to apply and tedious to remember — which makes it perfect skill material. lit-web-apps-skill encodes it for AI agents: a SKILL.md for the philosophy plus 13 reference guides, including a dedicated rendering-modes.md covering SSR streaming, SSG builds, hydration order, and islands.

Which means the prompt for everything in this post is roughly:

“Build a storefront: static marketing pages, server-rendered product catalog, client-rendered account area. Islands for interactive bits on static pages.”

…and the agent scaffolds the route table, the SSG build script, the streaming SSR server, the hydration entry point, and the escape-safe data serialization — following the same rules you just read. Same install pattern as my other skills:

git clone https://github.com/mikezupper/lit-web-apps-skill.git ~/.claude/skills/lit-web-apps

Does it actually hold up beyond a demo? That’s Part 3: five working apps — including a hybrid storefront that mixes all three modes from one route table, and a real-time dashboard that CSR was made for.

Continue to Part 3 → · Back to Part 1


Questions about a rendering setup you’re fighting with? X: @mikezupper — I read everything.