This is Part 1 of a three-part series on building full-stack web apps with Lit and the web platform — no metaframework. Part 2 covers SSR, SSG, and CSR from one route table; Part 3 walks through five real apps.
Count the frameworks you’ve learned and thrown away.
jQuery. Backbone. AngularJS — then Angular, which was a different thing entirely. React classes, then hooks, which made the classes obsolete. Redux, then the hooks that made Redux optional. Next.js pages router, then the app router, which invalidated half the Next.js content on the internet overnight. Server components. 'use client'.
Every one of those transitions cost you weeks. And here’s the part that stings: almost none of that knowledge transferred. The router conventions, the data-fetching idioms, the magic file names, the build adapters — all of it was framework-specific. It had an expiration date printed on it the day you learned it.
I’ve written before about this cycle of complexity — the industry’s habit of rebuilding the same abstractions with new names every three years. This series is about the exit ramp.
Because while we were all relearning routers, the platform caught up.
The Metaframework Tax, Itemized
Let’s be precise about what a metaframework actually charges you, because the sticker price (“it’s free, it’s open source!”) is not the real price:
- A proprietary component model. JSX isn’t HTML. Hooks have rules — rules — about call order that the language can’t enforce, so you install a linter to police them.
- A proprietary router. File-based conventions, catch-all segments, layout nesting rules. None of it transfers to any other tool.
- A proprietary server/client boundary.
'use client','use server', serialization limits, “you can’t use that hook here.” An entire mental model that exists nowhere outside one framework. - A proprietary build and deploy story. Vendor adapters, edge runtimes, image components that only work on certain hosts.
- A treadmill. Major versions invalidate patterns. Ecosystem libraries chase the framework. Your app is load-bearing on the churn.
Individually, each -ism seems reasonable. Together they form a body of knowledge as large as the platform itself — except the platform’s knowledge compounds, and the framework’s expires.
The <select> element has worked since 1995. document.querySelector will work in 2040. What’s your confidence interval on getServerSideProps?
What the Platform Ships Now
The reason metaframeworks won in 2015 is that the platform genuinely couldn’t do the job. Components, encapsulated styles, client routing, server rendering — you needed a framework for all of it.
That stopped being true, and almost nobody updated their priors. Here’s the platform’s current component stack:
Custom elements. Define <product-card> once and it works anywhere HTML works — a static page, a React app, a Vue app, a CMS template, an email preview tool. The browser manages the lifecycle: created, connected, disconnected, attribute changed. No virtual DOM required, because the DOM itself is the component tree.
Shadow DOM. Real style encapsulation, enforced by the browser — not by a naming convention (BEM), not by a build step (CSS modules), not by a runtime (CSS-in-JS). Styles don’t leak in; styles don’t leak out. The ::part() and ::slotted() selectors define the intentional styling API. Twenty years of CSS scoping hacks, closed.
Declarative Shadow DOM (Baseline 2024 — this is the big one). Shadow DOM used to require JavaScript, which made web components invisible to server rendering. Not anymore:
<product-card>
<template shadowrootmode="open">
<link rel="stylesheet" href="/card.css">
<h2><slot name="title"></slot></h2>
<p class="price"><slot name="price"></slot></p>
</template>
<span slot="title">Mechanical Keyboard</span>
<span slot="price">$149</span>
</product-card>
That component renders on first paint with zero JavaScript. The browser parses the template, attaches the shadow root, applies the encapsulated styles — before any script loads, or even if scripts never load. Server-rendered web components are no longer a contradiction; they’re a Baseline feature. (This becomes the foundation of everything in Part 2.)
And the supporting cast:
| You used to need | The platform now ships |
|---|---|
| React Router / framework routers | URLPattern + Navigation API |
| Portal libraries for modals/menus | <dialog>, Popover API (top layer, for free) |
| Form libraries (Formik, react-hook-form) | ElementInternals — custom elements that are form controls, with native validation |
| Animation libraries for route/list transitions | View Transitions API, FLIP via getBoundingClientRect |
| State libraries | Signals (TC39 standards track), context via composed events |
Every row of that table is a dependency you delete and a body of knowledge that stops expiring.
So Where Does Lit Come In?
Raw custom elements are verbose. Attribute reflection is manual, re-rendering is manual, templating is string concatenation. The platform gives you the primitives, not the ergonomics.
Lit is the ergonomics. ~5 KB, from the Google team that’s been closest to the web components standards since day one:
@customElement('product-card')
export class ProductCard extends LitElement {
@property() name = '';
@property({ type: Number }) price = 0;
static styles = css`
:host { display: block; container-type: inline-size; }
.price { color: var(--brand, oklch(60% 0.15 230)); }
`;
render() {
return html`
<h2>${this.name}</h2>
<p class="price">${formatPrice(this.price)}</p>
<button @click=${this.#addToCart}>Add to cart</button>
`;
}
}
Reactive properties that re-render efficiently on change. Tagged-template HTML that updates only the dynamic bindings — no virtual DOM diffing, because the template structure is static and Lit knows exactly which text node holds ${this.name}. Scoped styles via standard CSS in the shadow root.
Here’s the critical distinction, and it’s the entire thesis of this series: Lit is a layer on the platform, not a replacement for it. That render() returns real DOM into a real shadow root on a real custom element. The component registers with customElements.define. Events are DOM events. Forms are forms. Remove Lit someday and you still have a custom element contract; your component’s consumers never knew Lit was there.
Compare: remove React from a React app and you have nothing. The framework is the app. That’s the dependency relationship metaframeworks want — and the one the platform never asks for.
The platform is the framework. Lit is just the good ergonomics.
“But Frameworks Give Me SSR/SSG/Routing/DX…”
Right — this is the real objection, and it deserves a real answer. First, terms, because they’re the heart of the next post:
- CSR — client-side rendering. The server sends a nearly empty HTML shell; JavaScript builds the page in the browser. Great for logged-in app screens; rough on first paint and SEO.
- SSR — server-side rendering. A server generates the full HTML per request, so the browser paints real content immediately — then JavaScript “hydrates” it to make it interactive. Great for catalogs, feeds, anything crawlable and dynamic.
- SSG — static site generation. Same rendering, but done once at build time — the output is plain HTML files you can host anywhere. The fastest option, for content that doesn’t change per visitor.
Nobody adopts Next.js for JSX; they adopt it for this — the app-level stuff: server rendering, static generation, routing, data loading, production defaults.
That’s exactly what the rest of this series delivers, without the framework:
- Part 2 — SSR, SSG, CSR: One Route Table, No Metaframework. How
@lit-labs/ssrrenders components to Declarative Shadow DOM per-request (SSR) or at build time (SSG), how the same route table drives both plus client-side rendering, and how islands give you zero-JS-by-default pages that hydrate on demand. - Part 3 — Five Apps, Zero Frameworks. The receipts: five working applications — a static marketing site, a kanban board, a real-time dashboard streaming 50,000 log rows, a full hybrid-rendered storefront, and a design system — all built with an agent skill I wrote that teaches AI agents this entire discipline.
Same philosophy as my semantic-html and modern-css skills: the platform is enough — your agent just needs to be told.
Continue to Part 2 →
Building along, or think I’m wrong about your favorite framework? Find me on X: @mikezupper — the whole series drops there first.
